From a2de650b5232343570252e6cc2a091c760d72936 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Sun, 18 Oct 2026 18:08:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=BD=AC=E6=8D=A2=E6=B8=B2=E6=9F=93):=20?= =?UTF-8?q?=E4=BF=9D=E7=95=99PDF=E7=BB=93=E6=9E=84=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 2 +- go.sum | 4 +- internal/base/font/ttf.go | 1 - internal/base/geom/stroke.go | 18 +- internal/codec/pdf/export.go | 11 +- ofdgo_editor_metadata.go | 74 +++ ofdgo_editor_structure.go | 4 +- ofdgo_editor_structure_content.go | 158 ++++-- ofdgo_meta.go | 43 ++ ofdgo_pdf.go | 338 +++++++++--- ofdgo_pdf_annot.go | 57 +- ofdgo_pdf_clip.go | 23 +- ofdgo_pdf_composite.go | 11 +- ofdgo_pdf_composite_draw.go | 16 + ofdgo_pdf_composite_pattern.go | 2 +- ofdgo_pdf_form.go | 62 ++- ofdgo_pdf_geometry.go | 35 +- ofdgo_pdf_group.go | 64 ++- ofdgo_pdf_image.go | 5 +- ofdgo_pdf_metadata.go | 352 ++++++++++++ ofdgo_pdf_pattern.go | 47 +- ofdgo_pdf_structure.go | 879 ++++++++++++++++++++++++++++++ ofdgo_pdf_text.go | 12 +- ofdgo_pdf_text_replacement.go | 79 ++- ofdgo_renderer_geom_canvas.go | 23 +- ofdgo_renderer_output_canvas.go | 39 +- ofdgo_renderer_path_canvas.go | 6 + ofdgo_renderer_pdf_canvas.go | 10 + ofdgo_structure.go | 17 +- ofdgo_structure_xml.go | 14 +- ofdgo_writer.go | 9 +- 31 files changed, 2166 insertions(+), 249 deletions(-) create mode 100644 ofdgo_pdf_metadata.go create mode 100644 ofdgo_pdf_structure.go diff --git a/go.mod b/go.mod index 2ff559b..74a3f09 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/xiaoqidun/guigo v0.0.0-20261017165144-57ca5ff5f547 github.com/xiaoqidun/j2kgo v1.0.2 github.com/xiaoqidun/jbig2 v1.1.8 - github.com/xiaoqidun/pdfgo v0.0.0-20261017161421-0b88b77eefc9 + github.com/xiaoqidun/pdfgo v0.0.0-20261018100651-e306ca887a36 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 23b6632..4127e0d 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,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-20261017161421-0b88b77eefc9 h1:DsWSdFwsDpI8k0jadqcnGbpJuOpT+tIVIX5JfDFS2GQ= -github.com/xiaoqidun/pdfgo v0.0.0-20261017161421-0b88b77eefc9/go.mod h1:AaabzsObZ8Z76AcaWbo6swRROrJsblyIGB7HbieeJA4= +github.com/xiaoqidun/pdfgo v0.0.0-20261018100651-e306ca887a36 h1:PkJ4BNsjOgIlTIClR9DngaGyka9AYMb7d1GkhOuXzi8= +github.com/xiaoqidun/pdfgo v0.0.0-20261018100651-e306ca887a36/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/internal/base/font/ttf.go b/internal/base/font/ttf.go index 8fc3007..1829c62 100644 --- a/internal/base/font/ttf.go +++ b/internal/base/font/ttf.go @@ -29,7 +29,6 @@ func NormalizeOS2(data []byte) []byte { var size int switch binary.BigEndian.Uint16(data) { case 0: - // 旧式68字节表保持原样,不将截断的78字节表推断为旧式表 size = 78 case 1: size = 86 diff --git a/internal/base/geom/stroke.go b/internal/base/geom/stroke.go index 7d56a5e..bde3913 100644 --- a/internal/base/geom/stroke.go +++ b/internal/base/geom/stroke.go @@ -17,6 +17,7 @@ package geom import ( "fmt" "math" + "slices" ) // UnsupportedError 表示当前几何实现无法展开的描边形态 @@ -347,7 +348,22 @@ func (line geometryPolyline) stroke(result Path, options StrokeOptions) (Path, e } return result, nil } - directions := make([]Point, len(points)-1) + segments := len(points) - 1 + joins := segments - 1 + caps := 0 + if line.closed { + joins++ + } else if options.Cap == "Round" { + caps = 8 + } else if options.Cap == "Square" { + caps = 10 + } + joinSize := 4 + if options.Join == "" || options.Join == "Miter" { + joinSize = 5 + } + result = slices.Grow(result, min(segments*5+joins*joinSize+caps, 1<<20-len(result))) + directions := make([]Point, segments) for i := range directions { a, b := points[i], points[i+1] length := math.Hypot(b.X-a.X, b.Y-a.Y) diff --git a/internal/codec/pdf/export.go b/internal/codec/pdf/export.go index 67798e2..d3722b9 100644 --- a/internal/codec/pdf/export.go +++ b/internal/codec/pdf/export.go @@ -31,10 +31,11 @@ type TextPage struct { Replacements []pdfgo.TextReplacement } -// WriteOptions 保存PDF最终写出所需的像素、文字和导航修正 +// WriteOptions 保存PDF最终写出所需的元数据、像素、文字和导航修正 // Images逐页记录绘制图像,nil图像表示不替换;Links保留全部链接位置,包括矩形区域的nil项 // PrepareRewrite在页面引用确定后填充导航动作,不得提前写出PDF type WriteOptions struct { + Information pdfgo.Dictionary Images [][]image.Image Text []TextPage Links map[int][]*pdfgo.LinkRegion @@ -49,7 +50,7 @@ type WriteOptions struct { // 返回: error 资源、压缩或写入错误 func Write(ctx context.Context, data []byte, writer io.Writer, settings WriteOptions) error { nativeWrite := settings.PrepareRewrite != nil - if !settings.ExactImages && !settings.ExactText && !nativeWrite && settings.Optimization.Compression.Mode == pdfgo.CompressionUnchanged { + if !settings.ExactImages && !settings.ExactText && !nativeWrite && len(settings.Information) == 0 && settings.Optimization.Compression.Mode == pdfgo.CompressionUnchanged { if err := ctx.Err(); err != nil { return err } @@ -65,6 +66,10 @@ func Write(ctx context.Context, data []byte, writer io.Writer, settings WriteOpt } defer reader.Close() if !settings.ExactImages && !settings.ExactText && !nativeWrite { + if len(settings.Information) != 0 { + _, err := reader.RewriteTo(ctx, writer, pdfgo.RewriteOptions{Information: settings.Information, Optimization: settings.Optimization}) + return err + } _, err := reader.OptimizeTo(ctx, writer, settings.Optimization) return err } @@ -171,7 +176,7 @@ func Write(ctx context.Context, data []byte, writer io.Writer, settings WriteOpt if pages != len(settings.Images) { return fmt.Errorf("PDF output page missing") } - options := pdfgo.RewriteOptions{Images: images, ImageOptions: pdfgo.ImageWriteOptions{PreblendBinary: true}, LinkRegions: links, TextReplacements: texts, Optimization: settings.Optimization} + options := pdfgo.RewriteOptions{Information: settings.Information, Images: images, ImageOptions: pdfgo.ImageWriteOptions{PreblendBinary: true}, LinkRegions: links, TextReplacements: texts, Optimization: settings.Optimization} if nativeWrite { if err := settings.PrepareRewrite(ctx, reader, references, &options); err != nil { return err diff --git a/ofdgo_editor_metadata.go b/ofdgo_editor_metadata.go index 81c8646..651ed96 100644 --- a/ofdgo_editor_metadata.go +++ b/ofdgo_editor_metadata.go @@ -19,6 +19,7 @@ import ( "encoding/xml" "fmt" "reflect" + "slices" ) // sourceInfoXML 更新文档描述字段,保留未识别内容 @@ -54,6 +55,16 @@ func editorUpdateInfoXML(data []byte, docID string, index int, before, after Doc if err != nil { return nil, err } + if !slices.Equal(before.Keywords, after.Keywords) { + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + data, err = editorKeywords(data, root.childAt("DocBody", index).child("DocInfo"), after.Keywords) + if err != nil { + return nil, err + } + } if !reflect.DeepEqual(before.CustomDatas, after.CustomDatas) { root, err := parseEditorXML(data) if err != nil { @@ -64,6 +75,69 @@ func editorUpdateInfoXML(data []byte, docID string, index int, before, after Doc return data, nil } +// editorKeywords 更新有序关键词,保留已有词条的属性及未修改内容 +// 入参: data 原XML, info 文档信息节点, values 关键词 +// 返回: []byte 修改后的XML, error 编码错误 +func editorKeywords(data []byte, info *editorXML, values []string) ([]byte, error) { + container := info.child("Keywords") + if len(values) == 0 { + if container == nil { + return data, nil + } + return editorPatchXML(data, []editorXMLPatch{{container.start, container.end, nil}}), nil + } + if container == nil { + var content []byte + for _, value := range values { + content = append(content, info.textXML("Keyword", value)...) + } + encoded, err := info.containerXML("Keywords", nil, content) + if err != nil { + return nil, err + } + if info.open == info.end { + return editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, info, encoded)}), nil + } + position := info.close + for _, child := range info.children { + if child.matchesOFD("Creator") || child.matchesOFD("CreatorVersion") || child.matchesOFD("CustomDatas") { + position = child.start + break + } + } + return editorPatchXML(data, []editorXMLPatch{{position, position, encoded}}), nil + } + var patches []editorXMLPatch + index := 0 + for _, child := range container.children { + if !child.matchesOFD("Keyword") { + continue + } + var encoded []byte + if index < len(values) { + var err error + encoded, err = editorXMLTextValue(data, child, editorImportText(data, child), values[index]) + if err != nil { + return nil, err + } + } + patches = append(patches, editorXMLPatch{child.start, child.end, encoded}) + index++ + } + var added []byte + for ; index < len(values); index++ { + added = append(added, container.textXML("Keyword", values[index])...) + } + if len(added) != 0 { + if container.open == container.end { + patches = append(patches, editorXMLContent(data, container, added)) + } else { + patches = append(patches, editorXMLPatch{container.close, container.close, added}) + } + } + return editorPatchXML(data, patches), nil +} + // editorCreatorXML 更新指定文档的制作软件,移除原软件版本,保留其余根索引内容 // 入参: data 当前根索引XML, docID 缺少文档描述时使用的标识, index 文档索引 // 返回: []byte 修改后的XML, error 解析错误 diff --git a/ofdgo_editor_structure.go b/ofdgo_editor_structure.go index 143bba8..44d8cf5 100644 --- a/ofdgo_editor_structure.go +++ b/ofdgo_editor_structure.go @@ -476,14 +476,12 @@ func rewriteStructureReferences(ctx context.Context, data []byte, tag CustomTag, } } var patches []editorXMLPatch - count := 0 var walk func(*editorXML, int) error walk = func(node *editorXML, depth int) error { if err := ctx.Err(); err != nil { return err } - count++ - if depth > 128 || count > 100000 { + if depth > 128 { return fmt.Errorf("custom tag structure limit exceeded") } if node.name.Space != root.name.Space { diff --git a/ofdgo_editor_structure_content.go b/ofdgo_editor_structure_content.go index 04d86dc..59849c5 100644 --- a/ofdgo_editor_structure_content.go +++ b/ofdgo_editor_structure_content.go @@ -40,39 +40,67 @@ func (e *Editor) Structure() ([]Structure, error) { // 入参: tag 类型、命名空间及可选Schema位置, root 根节点 // 返回: int 从0开始的标引索引, error 校验或提交错误 func (e *Editor) AddStructure(tag CustomTag, root StructureNode) (int, error) { + data, err := encodeStructureNode(root) + if err != nil { + return 0, err + } + return e.addStructureXML(context.Background(), tag, data) +} + +// AddStructureXML 添加XML标引原文,保留处理指令、注释、前缀及混合文本 +// FileLoc由库分配,SchemaLoc仅引用包内已有文件,返回后不再引用输入数据 +// 入参: tag 标引入口, data 完整XML +// 返回: int 从0开始的标引索引, error 解析、引用或提交错误 +func (e *Editor) AddStructureXML(tag CustomTag, data []byte) (int, error) { + return e.AddStructureXMLContext(context.Background(), tag, data) +} + +// AddStructureXMLContext 校验并保存XML标引原文,取消或失败时不提交编辑状态 +// 入参: ctx 取消上下文, tag 标引入口, data 完整XML +// 返回: int 从0开始的标引索引, error 解析、引用、提交或取消错误 +func (e *Editor) AddStructureXMLContext(ctx context.Context, tag CustomTag, data []byte) (int, error) { + if err := ctx.Err(); err != nil { + return 0, err + } + return e.addStructureXML(ctx, tag, bytes.Clone(data)) +} + +// addStructureXML 接管独立XML数据并提交标引,复用编码或解码结果而不再次复制 +// 入参: ctx 取消上下文, tag 标引入口, data 独立XML数据,提交后不再修改 +// 返回: int 标引索引, error 校验、提交或取消错误 +func (e *Editor) addStructureXML(ctx context.Context, tag CustomTag, data []byte) (int, error) { + if err := ctx.Err(); err != nil { + return 0, err + } if strings.TrimSpace(tag.TypeID) == "" || tag.FileLoc != "" { return 0, fmt.Errorf("custom tag requires TypeID and an automatically assigned FileLoc") } if !structureXMLText(tag.TypeID) || !structureXMLText(tag.NameSpace) || !structureXMLText(tag.SchemaLoc) { return 0, fmt.Errorf("invalid custom tag metadata") } - data, err := encodeStructureNode(root) + tree, err := parseStructure(ctx, data, tag) if err != nil { return 0, err } - tree, err := parseStructure(context.Background(), data, tag) - if err != nil { - return 0, err - } - objects, err := e.validateStructureReferences(tree, tag, nil) + objects, err := e.validateStructureReferencesContext(ctx, tree, tag, nil) if err != nil { return 0, err } base := e.source if base == nil { - base, err = e.importBase() + base, err = e.importBaseContext(ctx) if err != nil { return 0, err } } if tag.SchemaLoc != "" { name := base.reader.ResPath(tag.SchemaLoc) - if _, err := base.reader.readFile(name); err != nil { + if _, err := base.reader.readFileViewContext(ctx, name); err != nil { return 0, err } tag.SchemaLoc = "/" + name } - name, index, container, entries, err := editorStructureIndex(base, nil) + name, index, container, entries, err := editorStructureIndexContext(ctx, base, nil) if err != nil { return 0, err } @@ -96,11 +124,11 @@ func (e *Editor) AddStructure(tag CustomTag, root StructureNode) (int, error) { parts[name] = editorPatchXML(index, []editorXMLPatch{editorXMLContent(index, container, entry)}) if base.document.CustomTags.Path == "" && name != base.reader.documentRoot { docName := base.reader.documentRoot - docData, err := base.reader.readFile(docName) + docData, err := base.reader.readFileViewContext(ctx, docName) if err != nil { return 0, err } - doc, err := parseEditorXML(docData) + doc, err := parseEditorXMLContext(ctx, docData) if err != nil { return 0, err } @@ -109,7 +137,23 @@ func (e *Editor) AddStructure(tag CustomTag, root StructureNode) (int, error) { return 0, err } } - return len(entries), e.commitStructureParts(base, parts, objects, len(entries)) + return len(entries), e.commitStructurePartsContext(ctx, base, parts, objects, len(entries)) +} + +// UpdateStructureXML 替换标引XML原文,保留入口属性并隔离共享内容文件 +// 入参: index 标引索引, data 完整XML,返回后不再引用输入数据 +// 返回: error 索引、解析、引用或提交错误 +func (e *Editor) UpdateStructureXML(index int, data []byte) error { + return e.UpdateStructureXMLContext(context.Background(), index, data) +} + +// UpdateStructureXMLContext 校验并原子替换标引原文,相同内容不产生撤销记录 +// 入参: ctx 取消上下文, index 标引索引, data 完整XML +// 返回: error 索引、解析、引用、提交或取消错误 +func (e *Editor) UpdateStructureXMLContext(ctx context.Context, index int, data []byte) error { + return e.editStructureContext(ctx, index, func([]byte, *editorXML, CustomTag) ([]byte, error) { + return bytes.Clone(data), nil + }) } // DeleteStructure 删除标引入口,不删除正文或其他入口共享的资源 @@ -264,6 +308,16 @@ func (e *Editor) SetStructureReferences(index int, path []int, references []Stru // 入参: base 编辑包快照, parts 本次输出条目,可为nil // 返回: string 索引文件路径, []byte 原文, *editorXML 容器, []*editorXML 标引入口, error 读取错误 func editorStructureIndex(base *editorSource, parts map[string][]byte) (string, []byte, *editorXML, []*editorXML, error) { + return editorStructureIndexContext(context.Background(), base, parts) +} + +// editorStructureIndexContext 读取或准备标引索引,文件读取及解析期间响应取消 +// 入参: ctx 取消上下文, base 编辑包快照, parts 本次输出条目,可为nil +// 返回: string 索引路径, []byte 原文, *editorXML 容器, []*editorXML 入口, error 读取或取消错误 +func editorStructureIndexContext(ctx context.Context, base *editorSource, parts map[string][]byte) (string, []byte, *editorXML, []*editorXML, error) { + if err := ctx.Err(); err != nil { + return "", nil, nil, nil, err + } name := base.reader.documentRoot if base.document.CustomTags.Path != "" { name = base.reader.ResPath(base.document.CustomTags.Path) @@ -271,12 +325,12 @@ func editorStructureIndex(base *editorSource, parts map[string][]byte) (string, data, ok := parts[name] if !ok { var err error - data, err = base.reader.readFile(name) + data, err = base.reader.readFileViewContext(ctx, name) if err != nil { return "", nil, nil, nil, err } } - root, err := parseEditorXML(data) + root, err := parseEditorXMLContext(ctx, data) if err != nil { return "", nil, nil, nil, err } @@ -286,7 +340,7 @@ func editorStructureIndex(base *editorSource, parts map[string][]byte) (string, name = packageAvailableName(base.reader, nil, base.reader.RootDir+"/CustomTags.xml") data, err = root.containerXML("CustomTags", nil, nil) if err == nil { - root, err = parseEditorXML(data) + root, err = parseEditorXMLContext(ctx, data) } if err != nil { return "", nil, nil, nil, err @@ -321,10 +375,20 @@ func editorStructureNode(root *editorXML, path []int) (*editorXML, error) { // 入参: index 标引索引, edit 内容变换 // 返回: error 校验、解析或提交错误 func (e *Editor) editStructure(index int, edit func([]byte, *editorXML, CustomTag) ([]byte, error)) error { + return e.editStructureContext(context.Background(), index, edit) +} + +// editStructureContext 校验并提交标引编辑,快照读取及引用遍历期间响应取消 +// 入参: ctx 取消上下文, index 标引索引, edit 内容变换 +// 返回: error 校验、解析、提交或取消错误 +func (e *Editor) editStructureContext(ctx context.Context, index int, edit func([]byte, *editorXML, CustomTag) ([]byte, error)) error { + if err := ctx.Err(); err != nil { + return err + } if e.source == nil { return fmt.Errorf("custom tag structure not found: %d", index) } - tags, err := e.source.reader.CustomTags() + tags, err := e.source.reader.CustomTagsContext(ctx) if err != nil { return err } @@ -333,45 +397,51 @@ func (e *Editor) editStructure(index int, edit func([]byte, *editorXML, CustomTa } tag := tags[index] name := e.source.reader.ResPath(tag.FileLoc) - snapshot, err := e.Reader() + snapshot, err := e.ReaderContext(ctx) if err != nil { return err } defer snapshot.Close() - current, err := snapshot.CustomTags() + current, err := snapshot.CustomTagsContext(ctx) if err != nil { return err } if index >= len(current) { return fmt.Errorf("custom tag structure not found: %d", index) } - data, err := snapshot.readFile(snapshot.ResPath(current[index].FileLoc)) + data, err := snapshot.readFileViewContext(ctx, snapshot.ResPath(current[index].FileLoc)) if err != nil { return err } - if _, err := parseStructure(context.Background(), data, tag); err != nil { + if _, err := parseStructure(ctx, data, tag); err != nil { return err } - root, err := parseEditorXML(data) + root, err := parseEditorXMLContext(ctx, data) if err != nil { return err } updated, err := edit(data, root, tag) - if err != nil || bytes.Equal(data, updated) { - return err - } - tree, err := parseStructure(context.Background(), updated, tag) if err != nil { return err } - objects, err := e.validateStructureReferences(tree, tag, snapshot) + if err := ctx.Err(); err != nil { + return err + } + if bytes.Equal(data, updated) { + return nil + } + tree, err := parseStructure(ctx, updated, tag) + if err != nil { + return err + } + objects, err := e.validateStructureReferencesContext(ctx, tree, tag, snapshot) if err != nil { return err } parts := make(map[string][]byte) for i, other := range tags { if i != index && e.source.reader.ResPath(other.FileLoc) == name { - indexName, indexData, _, entries, err := editorStructureIndex(e.source, nil) + indexName, indexData, _, entries, err := editorStructureIndexContext(ctx, e.source, nil) if err != nil { return err } @@ -387,13 +457,20 @@ func (e *Editor) editStructure(index int, edit func([]byte, *editorXML, CustomTa } } parts[name] = updated - return e.commitStructureParts(e.source, parts, objects, index) + return e.commitStructurePartsContext(ctx, e.source, parts, objects, index) } // commitStructureParts 提交标引并记录有效目标及已应用的编号迁移,供后续编辑维护引用 // 入参: base 原包, parts 修改条目, objects 按页面编号记录的有效图元, index 标引入口序号 // 返回: error 提交错误 func (e *Editor) commitStructureParts(base *editorSource, parts map[string][]byte, objects map[string]map[string]bool, index int) error { + return e.commitStructurePartsContext(context.Background(), base, parts, objects, index) +} + +// commitStructurePartsContext 准备标引引用记录并原子提交,取消时保留原编辑状态 +// 入参: ctx 取消上下文, base 原包, parts 修改条目, objects 有效图元, index 标引入口序号 +// 返回: error 提交或取消错误 +func (e *Editor) commitStructurePartsContext(ctx context.Context, base *editorSource, parts map[string][]byte, objects map[string]map[string]bool, index int) error { next := *base if len(e.structureRemaps) != 0 { next.structureStarts = maps.Clone(base.structureStarts) @@ -407,21 +484,34 @@ func (e *Editor) commitStructureParts(base *editorSource, parts map[string][]byt next.structureObjects = make(map[string]map[string]bool) } for page, ids := range objects { + if err := ctx.Err(); err != nil { + return err + } known := make(map[string]bool, len(next.structureObjects[page])+len(ids)) maps.Copy(known, next.structureObjects[page]) maps.Copy(known, ids) next.structureObjects[page] = known } - return e.commitAnnotationParts(&next, parts) + return e.commitAnnotationPartsContext(ctx, &next, parts) } // validateStructureReferences 检查标引目标是否位于当前文档和版本,不依赖字体或图像解码 // 入参: root 待提交标引树, tag 标引入口, r 当前操作的编辑快照,为nil时按需创建 // 返回: map[string]map[string]bool 已校验目标, error 页面或图元缺失错误 func (e *Editor) validateStructureReferences(root *StructureNode, tag CustomTag, r *Reader) (map[string]map[string]bool, error) { + return e.validateStructureReferencesContext(context.Background(), root, tag, r) +} + +// validateStructureReferencesContext 校验当前文档及版本的引用,遍历及快照读取期间响应取消 +// 入参: ctx 取消上下文, root 标引树, tag 标引入口, r 可复用的编辑快照 +// 返回: map[string]map[string]bool 已校验目标, error 引用或取消错误 +func (e *Editor) validateStructureReferencesContext(ctx context.Context, root *StructureNode, tag CustomTag, r *Reader) (map[string]map[string]bool, error) { var refs []StructureReference var collect func(*StructureNode, bool) error collect = func(node *StructureNode, official bool) error { + if err := ctx.Err(); err != nil { + return err + } official = official && node.Namespace == root.Namespace if official && node.Name == "ObjectRef" && len(node.Children) != 0 { return fmt.Errorf("ObjectRef cannot contain child nodes") @@ -442,28 +532,32 @@ func (e *Editor) validateStructureReferences(root *StructureNode, tag CustomTag, } if r == nil { var err error - r, err = e.Reader() + r, err = e.ReaderContext(ctx) if err != nil { return nil, err } defer r.Close() } - doc, err := r.Doc() + doc, err := r.DocContext(ctx) if err != nil { return nil, err } renderer := e.newRenderer(r) + renderer.pageContext = ctx pages := signaturePageNumbers(doc) checked := make(map[int]map[string]bool) result := make(map[string]map[string]bool) for _, ref := range refs { + if err := ctx.Err(); err != nil { + return nil, err + } page, _ := resourceValue(pages, ref.PageID) if page == 0 { return nil, fmt.Errorf("custom tag page not found: %s", ref.PageID) } objects := checked[page] if objects == nil { - content, err := r.PageContentByIndex(page - 1) + content, err := r.PageContentByIndexContext(ctx, page-1) if err != nil { return nil, err } diff --git a/ofdgo_meta.go b/ofdgo_meta.go index 3c7c4c0..4fceda0 100644 --- a/ofdgo_meta.go +++ b/ofdgo_meta.go @@ -49,11 +49,15 @@ type DocInfo struct { Abstract string `xml:"Abstract"` CreationDate string `xml:"CreationDate"` ModDate string `xml:"ModDate"` + Keywords Keywords `xml:"Keywords,omitempty"` Creator string `xml:"Creator"` CreatorVersion string `xml:"CreatorVersion"` CustomDatas *CustomDatas `xml:"CustomDatas"` } +// Keywords 保存有序关键词,保留重复及空文本,空集合不生成XML节点 +type Keywords []string + // CustomDatas 自定义数据集合 type CustomDatas struct { CustomData []CustomData `xml:"CustomData"` @@ -129,6 +133,8 @@ func (info *DocInfo) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error target = &info.CreationDate case "ModDate": target = &info.ModDate + case "Keywords": + target = &info.Keywords case "Creator": target = &info.Creator case "CreatorVersion": @@ -142,6 +148,43 @@ func (info *DocInfo) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error }) } +// UnmarshalXML 按原顺序读取标准关键词,跳过外部扩展条目 +// 入参: d XML解码器, start 起始节点 +// 返回: error 解析错误 +func (keywords *Keywords) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + *keywords = nil + return decodeOFDChildren(d, start, func(d *xml.Decoder, node xml.StartElement) error { + if node.Name.Local != "Keyword" { + return d.Skip() + } + var value string + if err := d.DecodeElement(&value, &node); err != nil { + return err + } + *keywords = append(*keywords, value) + return nil + }) +} + +// MarshalXML 编码非空关键词集合,子节点沿用集合的命名空间 +// 入参: e XML编码器, start 起始节点 +// 返回: error 编码错误 +func (keywords Keywords) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + if len(keywords) == 0 { + return nil + } + if err := e.EncodeToken(start); err != nil { + return err + } + for _, value := range keywords { + child := xml.StartElement{Name: xml.Name{Space: start.Name.Space, Local: "Keyword"}} + if err := e.EncodeElement(value, child); err != nil { + return err + } + } + return e.EncodeToken(start.End()) +} + // UnmarshalXML 按原顺序读取OFD自定义字段,跳过外部扩展条目 // 入参: d XML解码器, start 起始节点 // 返回: error 解析错误 diff --git a/ofdgo_pdf.go b/ofdgo_pdf.go index 4c6995f..f5365b4 100644 --- a/ofdgo_pdf.go +++ b/ofdgo_pdf.go @@ -18,6 +18,8 @@ import ( "context" "fmt" "io" + "slices" + "sort" codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf" "github.com/xiaoqidun/pdfgo" @@ -66,66 +68,138 @@ type PDFImportReport struct { // pdfImporter 保存单次转换的对象与资源状态 type pdfImporter struct { - ctx context.Context - reader *pdfgo.Reader - editor *Editor - renderer *Renderer - report PDFImportReport - matrix pdfgo.Matrix - page int - pageActionPage int - fontIDs map[*pdfgo.Font]string - fonts map[*pdfgo.Font]*pdfImportedFont - symbolFonts map[*pdfgo.Font]*pdfImportedFont - fontWarnings map[string]bool - clipTexts map[*pdfgo.TextClip][]TextObject - clipPaths *renderCache[[32]byte, pdfClipPath] - cmykSpace string - rgbSpace string - iccSpaces map[pdfICCKey]string - objects *pdfObjectBuffer - formResources *pdfFormCache - validation *editorValidation - pages map[pdfgo.Reference]*pdfgo.Page - pageOrder []*pdfgo.Page - pageIDs map[pdfgo.Reference]string - pageIndexes map[pdfgo.Reference]int - destinationBoxes map[pdfgo.Reference]Box - imageIDs map[pdfImageKey][]pdfImageResource - imageContents *renderCache[pdfImageContentKey, pdfImageContent] - attachmentIDs map[pdfAttachmentKey]string - modelAttachments map[*pdfgo.Stream]string - mediaResources map[pdfMediaFileKey]pdfMediaResource - mediaInstances map[pdfMediaInstanceKey]pdfMediaResource - movieBounds map[*Movie]Box - renditions *pdfRenditionImport - maskClips map[*pdfgo.SoftMask]pdfgo.Path - pageBox pdfgo.Rectangle - pageWidth float64 - pageHeight float64 - pendingPath *pdfgo.PathMark - warning func(pdfgo.Diagnostic) - rasterDPI float64 - rasterWarned bool - compositeCache *pdfCompositeCache - compositeNodes []pdfCompositeNode - compositeSpace *pdfgo.ColorSpace - patternCells *renderCache[pdfPatternCellKey, *Pattern] - annotationData []byte - resolveFile pdfgo.FileResolver - resolveReference pdfgo.ReferenceResolver - references *codecpdf.ReferenceCache - halftoneWarnings map[*pdfgo.Halftone]bool - halftones map[string]*pdfgo.Halftone - transferBackdrop bool - transferModel pdfgo.Name - compression CompressionOptions + ctx context.Context + reader *pdfgo.Reader + editor *Editor + renderer *Renderer + report PDFImportReport + matrix pdfgo.Matrix + page int + pageActionPage int + fontIDs map[*pdfgo.Font]string + fonts map[*pdfgo.Font]*pdfImportedFont + symbolFonts map[*pdfgo.Font]*pdfImportedFont + fontWarnings map[string]bool + clipTexts map[*pdfgo.TextClip][]TextObject + textOrigins map[*pdfgo.ReplacementText]pdfObjectOrigin + clipPaths *renderCache[[32]byte, pdfClipPath] + cmykSpace string + rgbSpace string + iccSpaces map[pdfICCKey]string + objects *pdfObjectBuffer + structureBindings *pdfStructureBindings + annotationBindings []pdfStructureObjectBinding + formResources *pdfFormCache + validation *editorValidation + pages map[pdfgo.Reference]*pdfgo.Page + pageOrder []*pdfgo.Page + pageIDs map[pdfgo.Reference]string + pageIndexes map[pdfgo.Reference]int + destinationBoxes map[pdfgo.Reference]Box + imageIDs map[pdfImageKey][]pdfImageResource + imageContents *renderCache[pdfImageContentKey, pdfImageContent] + attachmentIDs map[pdfAttachmentKey]string + modelAttachments map[*pdfgo.Stream]string + mediaResources map[pdfMediaFileKey]pdfMediaResource + mediaInstances map[pdfMediaInstanceKey]pdfMediaResource + movieBounds map[*Movie]Box + renditions *pdfRenditionImport + maskClips map[*pdfgo.SoftMask]pdfgo.Path + pageBox pdfgo.Rectangle + pageWidth float64 + pageHeight float64 + warning func(pdfgo.Diagnostic) + rasterDPI float64 + rasterWarned bool + compositeCache *pdfCompositeCache + compositeNodes []pdfCompositeNode + compositeSpace *pdfgo.ColorSpace + patternCells *renderCache[pdfPatternCellKey, pdfPatternCell] + patternOrigins map[*Pattern]pdfObjectOrigin + annotationData []byte + resolveFile pdfgo.FileResolver + resolveReference pdfgo.ReferenceResolver + references *codecpdf.ReferenceCache + halftoneWarnings map[*pdfgo.Halftone]bool + halftones map[string]*pdfgo.Halftone + transferBackdrop bool + transferModel pdfgo.Name + compression CompressionOptions } // pdfObjectBuffer 分块收集导入对象,提交前只整理一次连续数据 type pdfObjectBuffer struct { - parts [][]GraphicObject - size int + parts [][]GraphicObject + sequences []pdfObjectSequence + size int +} + +// pdfObjectSequence 记录连续对象的来源区段,仅在区段变化时追加 +type pdfObjectSequence struct { + start int + pdfObjectOrigin +} + +// pdfObjectOrigin 保存图元的来源区段,混合区域同时保留参与合成的区段 +type pdfObjectOrigin struct { + sequence *pdfgo.ContentSequence + related []*pdfgo.ContentSequence +} + +// pdfOriginSet 按首次出现顺序收集来源,仅在出现不同区段时分配去重表 +type pdfOriginSet struct { + origin pdfObjectOrigin + seen map[*pdfgo.ContentSequence]bool + started bool +} + +// add 记录参与绘制的区段,空区段表示未标记内容 +// 入参: sequence 来源区段 +func (s *pdfOriginSet) add(sequence *pdfgo.ContentSequence) { + if !s.started { + s.origin.sequence, s.started = sequence, true + return + } + if sequence == s.origin.sequence || s.seen[sequence] { + return + } + if s.seen == nil { + s.seen = make(map[*pdfgo.ContentSequence]bool) + } + s.seen[sequence] = true + s.origin.related = append(s.origin.related, sequence) +} + +// equal 比较来源及其顺序,不合并不同来源的图元 +// 入参: other 待比较来源 +// 返回: bool 是否相同 +func (o pdfObjectOrigin) equal(other pdfObjectOrigin) bool { + return o.sequence == other.sequence && slices.Equal(o.related, other.related) +} + +// merge 合并参与混色的来源,不修改已有区段切片 +// 入参: other 另一来源 +// 返回: pdfObjectOrigin 去重后的来源 +func (o pdfObjectOrigin) merge(other pdfObjectOrigin) pdfObjectOrigin { + if o.equal(other) { + return o + } + result := o + copied := false + appendSequence := func(sequence *pdfgo.ContentSequence) { + if sequence != result.sequence && !slices.Contains(result.related, sequence) { + if !copied { + result.related = slices.Clone(result.related) + copied = true + } + result.related = append(result.related, sequence) + } + } + appendSequence(other.sequence) + for _, sequence := range other.related { + appendSequence(sequence) + } + return result } // ImportPDF 将PDF内容转换为独立OFD编辑文档,失败时不返回部分结果 @@ -159,7 +233,7 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI editor.SetRenderBackends(renderer.Backends()) editor.fontDirs, editor.fontFS = renderer.FontSources() editor.fontSourcesCache = renderer.fontSourcesCache - importer := pdfImporter{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{}} + importer := pdfImporter{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{}} readOptions := pdfgo.ReaderOptions{Password: options.Password, PasswordUTF8: options.PasswordUTF8, ShapeText: importer.shapePDFText} var diagnostics []pdfgo.Diagnostic var readWarning func(pdfgo.Diagnostic) @@ -173,6 +247,9 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI } defer reader.Close() importer.reader = reader + if err := importer.prepareStructure(ctx); err != nil { + return nil, PDFImportReport{}, fmt.Errorf("import PDF structure: %w", err) + } outputIntents, err := reader.OutputIntents() if err != nil { return nil, PDFImportReport{}, fmt.Errorf("import PDF output intents: %w", err) @@ -192,12 +269,6 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI importer.references = &codecpdf.ReferenceCache{} defer importer.references.Close() importer.halftoneWarnings = make(map[*pdfgo.Halftone]bool) - if security := reader.Encryption(); security != nil && !security.Owner && security.Permissions&0xf3c != 0xf3c { - if options.Strict { - return nil, PDFImportReport{}, &pdfgo.UnsupportedError{Feature: "PDF access permission conversion"} - } - importer.report.Warnings = append(importer.report.Warnings, pdfgo.Diagnostic{Message: "PDF access permissions not transferred to OFD"}) - } if !options.Strict { importer.warning = func(warning pdfgo.Diagnostic) { warning.Page = importer.page + 1 @@ -224,6 +295,9 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI } if len(editor.pages) != 0 { importer.page = -1 + if err := importer.documentMetadata(ctx); err != nil { + return nil, PDFImportReport{}, fmt.Errorf("import PDF metadata: %w", err) + } if err := importer.documentAttachments(ctx); err != nil { return nil, PDFImportReport{}, fmt.Errorf("import PDF attachments: %w", err) } @@ -251,26 +325,23 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI importer.compositeNodes = nil importer.compositeSpace = nil importer.patternCells = nil + importer.patternOrigins = nil importer.clipTexts = make(map[*pdfgo.TextClip][]TextObject) + importer.textOrigins = nil importer.clipPaths = nil importer.annotationData = nil + importer.annotationBindings = nil if err := importer.pageOpenActions(ctx, page); err != nil { return fmt.Errorf("import PDF page %d actions: %w", index+1, err) } if err := importer.compositePage(nil, func(visitor pdfgo.Visitor) error { return reader.WalkPage(ctx, page, visitor) }); err != nil { return fmt.Errorf("import PDF page %d: %w", index+1, err) } - if err := importer.flushPath(); err != nil { - return fmt.Errorf("import PDF page %d: %w", index+1, err) - } if err := importer.annotations(ctx, page, options.Strict); err != nil { return fmt.Errorf("import PDF page %d: %w", index+1, err) } - if len(importer.annotationData) != 0 { - if err := editor.appendAnnotations(index, importer.annotationData, true); err != nil { - return fmt.Errorf("import PDF page %d: %w", index+1, err) - } - importer.annotationData = nil + if err := importer.commitAnnotations(); err != nil { + return fmt.Errorf("import PDF page %d: %w", index+1, err) } if err := importer.commitObjects(); err != nil { return fmt.Errorf("import PDF page %d: %w", index+1, err) @@ -292,6 +363,9 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI if importer.report.Pages == 0 { return nil, PDFImportReport{}, fmt.Errorf("PDF contains no pages") } + if err := importer.documentStructures(ctx); err != nil { + return nil, PDFImportReport{}, fmt.Errorf("import PDF structure: %w", err) + } return editor, importer.report, nil } @@ -346,15 +420,60 @@ func (p *pdfImporter) objectValidation() *editorValidation { // appendObject 追加本次导入对象,按需建立独立暂存缓冲 // 入参: object 独立导入对象 func (p *pdfImporter) appendObject(object GraphicObject) { + p.appendSequenceObject(object, nil) +} + +// appendSequenceObject 追加对象并保留来源区段,供复合对象优化核对边界 +// 入参: object 独立导入对象, sequence 来源区段 +func (p *pdfImporter) appendSequenceObject(object GraphicObject, sequence *pdfgo.ContentSequence) { + p.appendOriginObject(object, pdfObjectOrigin{sequence: sequence}) +} + +// appendOriginObject 追加对象并保留全部来源区段 +// 入参: object 独立导入对象, origin 来源区段 +func (p *pdfImporter) appendOriginObject(object GraphicObject, origin pdfObjectOrigin) { + if len(p.patternOrigins) != 0 { + var fill *FillColor + var stroke *StrokeColor + switch object.Type { + case "PathObject": + fill, stroke = object.PathObject.FillColor, object.PathObject.StrokeColor + case "TextObject": + fill, stroke = object.TextObject.FillColor, object.TextObject.StrokeColor + } + if fill != nil { + if source, ok := p.patternOrigins[fill.Pattern]; ok { + origin = origin.merge(source) + } + } + if stroke != nil { + if source, ok := p.patternOrigins[stroke.Pattern]; ok { + origin = origin.merge(source) + } + } + } if p.objects == nil { p.objects = &pdfObjectBuffer{} } - p.objects.append(object) + p.objects.appendOrigin(object, origin) } // append 追加对象,小批量连续存储,超过阈值后不复制前序片段 // 入参: object 独立对象 func (b *pdfObjectBuffer) append(object GraphicObject) { + b.appendSequence(object, nil) +} + +// appendSequence 按区段连续存储对象,不为未标记内容分配区段记录 +// 入参: object 独立对象, sequence 来源区段 +func (b *pdfObjectBuffer) appendSequence(object GraphicObject, sequence *pdfgo.ContentSequence) { + b.appendOrigin(object, pdfObjectOrigin{sequence: sequence}) +} + +// appendOrigin 按来源连续存储对象,混合区域保留多个区段 +// 入参: object 独立对象, origin 来源区段 +func (b *pdfObjectBuffer) appendOrigin(object GraphicObject, origin pdfObjectOrigin) { + b.recordOrigin(b.size, origin) index := len(b.parts) - 1 if index == 0 && len(b.parts[0]) == cap(b.parts[0]) && cap(b.parts[0]) < pdfObjectChunkLimit { part := b.parts[0] @@ -374,6 +493,66 @@ func (b *pdfObjectBuffer) append(object GraphicObject) { b.size++ } +// recordOrigin 按递增对象位置记录完整来源变化 +// 入参: index 对象位置, origin 来源区段 +func (b *pdfObjectBuffer) recordOrigin(index int, origin pdfObjectOrigin) { + var previous pdfObjectOrigin + if len(b.sequences) != 0 { + previous = b.sequences[len(b.sequences)-1].pdfObjectOrigin + } + if !previous.equal(origin) { + b.sequences = append(b.sequences, pdfObjectSequence{start: index, pdfObjectOrigin: origin}) + } +} + +// origin 获取暂存对象的全部来源,未标记对象或空缓冲返回空来源 +// 入参: index 对象位置 +// 返回: pdfObjectOrigin 来源区段 +func (b *pdfObjectBuffer) origin(index int) pdfObjectOrigin { + if b == nil { + return pdfObjectOrigin{} + } + next := sort.Search(len(b.sequences), func(i int) bool { return b.sequences[i].start > index }) + if next == 0 { + return pdfObjectOrigin{} + } + return b.sequences[next-1].pdfObjectOrigin +} + +// combinedOrigin 收集局部合成的来源区段,按首次出现顺序去重 +// 入参: ctx 取消上下文 +// 返回: pdfObjectOrigin 合并来源, error 取消错误 +func (b *pdfObjectBuffer) combinedOrigin(ctx context.Context) (pdfObjectOrigin, error) { + if err := ctx.Err(); err != nil { + return pdfObjectOrigin{}, err + } + if b.len() == 0 { + return pdfObjectOrigin{}, nil + } + origin := b.origin(0) + if len(b.sequences) <= 1 && (len(b.sequences) == 0 || b.sequences[0].start == 0) { + return origin, nil + } + var result pdfOriginSet + result.add(origin.sequence) + for _, sequence := range origin.related { + result.add(sequence) + } + for _, run := range b.sequences { + if err := ctx.Err(); err != nil { + return pdfObjectOrigin{}, err + } + result.add(run.sequence) + for _, sequence := range run.related { + if err := ctx.Err(); err != nil { + return pdfObjectOrigin{}, err + } + result.add(sequence) + } + } + return result.origin, nil +} + // len 获取暂存对象数量,空缓冲返回零 // 返回: int 对象数量 func (b *pdfObjectBuffer) len() int { @@ -392,6 +571,11 @@ func (b *pdfObjectBuffer) truncate(size int) { if size == b.len() { return } + for len(b.sequences) > 0 && b.sequences[len(b.sequences)-1].start >= size { + last := len(b.sequences) - 1 + b.sequences[last] = pdfObjectSequence{} + b.sequences = b.sequences[:last] + } remaining := size for index, part := range b.parts { if remaining <= len(part) { @@ -450,9 +634,19 @@ func (p *pdfImporter) commitObjects() error { if err != nil { return err } + var bindings []pdfStructureBinding + if p.structureBindings != nil { + bindings, err = p.objects.structureBindings(p.ctx) + if err != nil { + return err + } + } if err := p.objectValidation().appendOwnedObjects(p.ctx, p.page, objects); err != nil { return err } + if p.structureBindings != nil { + p.structureBindings.append(p.editor.pages[p.page].ID, objects, bindings) + } p.objects = nil return nil } diff --git a/ofdgo_pdf_annot.go b/ofdgo_pdf_annot.go index af36a52..d7e53ea 100644 --- a/ofdgo_pdf_annot.go +++ b/ofdgo_pdf_annot.go @@ -104,7 +104,6 @@ func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, a stamp := *p stamp.matrix = (pdfgo.Matrix{1, 0, 0, 1, -box.X, -box.Y}).Mul(p.matrix) stamp.objects = nil - stamp.pendingPath = nil stamp.report = PDFImportReport{} stamp.compositeCache = nil stamp.clipTexts = make(map[*pdfgo.TextClip][]TextObject) @@ -226,6 +225,33 @@ func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, a if err != nil { return fmt.Errorf("PDF annotation: %w", err) } + var binding pdfStructureObjectBinding + if p.structureBindings != nil { + binding.content, err = stamp.objects.structureBindings(ctx) + if err != nil { + return err + } + if annotation.Reference != (pdfgo.Reference{}) && annotation.Dictionary["StructParent"] != nil { + binding.source = pdfStructureObject{reader: p.reader, page: page.Reference, object: annotation.Reference} + } + } + if binding.source.object != (pdfgo.Reference{}) || len(binding.content) != 0 { + root, err := parseEditorXMLContext(ctx, data) + if err != nil { + return err + } + if appearance := root.child("Appearance"); appearance != nil { + for _, object := range appearance.children { + if err := ctx.Err(); err != nil { + return err + } + if id := object.attr("ID"); id != "" { + binding.ids = append(binding.ids, id) + } + } + } + p.annotationBindings = append(p.annotationBindings, binding) + } p.annotationData = append(p.annotationData, bytes.TrimPrefix(data, []byte(xml.Header))...) if len(actions) != 0 && annotation.Subtype == "Link" || len(clicks) != 0 { p.report.Links++ @@ -237,6 +263,35 @@ func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, a return nil } +// commitAnnotations 提交注解后登记最终外观标识,失败时保留待提交状态 +// 返回: error 注解提交错误 +func (p *pdfImporter) commitAnnotations() error { + if err := p.ctx.Err(); err != nil { + return err + } + if len(p.annotationData) == 0 { + return nil + } + if err := p.editor.appendAnnotations(p.page, p.annotationData, true); err != nil { + return err + } + for _, binding := range p.annotationBindings { + pageID := p.editor.pages[p.page].ID + if binding.source.object != (pdfgo.Reference{}) { + if p.structureBindings.objects == nil { + p.structureBindings.objects = make(map[pdfStructureObject][]StructureReference) + } + p.structureBindings.objects[binding.source] = append(p.structureBindings.objects[binding.source], StructureReference{PageID: pageID, ObjectIDs: binding.ids}) + } + for _, content := range binding.content { + ref := p.structureBindings.reference(pageID, content.sequence) + ref.ObjectIDs = append(ref.ObjectIDs, binding.ids[content.start:content.end]...) + } + } + p.annotationData, p.annotationBindings = nil, nil + return nil +} + // pdfAnnotationType 将具有静态外观的PDF注解类别对应到OFD注解类别 // 入参: subtype PDF注解类别 // 返回: string OFD注解类别,空字符串表示需单独处理 diff --git a/ofdgo_pdf_clip.go b/ofdgo_pdf_clip.go index ce20ad1..2f36894 100644 --- a/ofdgo_pdf_clip.go +++ b/ofdgo_pdf_clip.go @@ -61,11 +61,12 @@ func (p *pdfImporter) clipPath(path pdfgo.Path) (pdfClipPath, error) { return result, nil } -// sharedClipObjects 将连续重复的大裁剪区提取为复合对象,保留页面坐标和绘制顺序 +// sharedClipObjects 将连续重复的大裁剪区提取为复合对象,同步缓冲区及来源区段 // 入参: objects 本库生成且尚未分配标识的对象 // 返回: []GraphicObject 提取后的对象, error 资源创建或取消错误 func (p *pdfImporter) sharedClipObjects(objects []GraphicObject) ([]GraphicObject, error) { var output []GraphicObject + var mapped pdfObjectBuffer for start := 0; start < len(objects); { if err := p.ctx.Err(); err != nil { return nil, err @@ -73,7 +74,7 @@ func (p *pdfImporter) sharedClipObjects(objects []GraphicObject) ([]GraphicObjec clips := pdfPageClip(objects[start]) end := start + 1 if clips != nil { - for end < len(objects) && pdfMatchesPageClip(objects[end], clips) { + for end < len(objects) && p.objects.origin(start).equal(p.objects.origin(end)) && pdfMatchesPageClip(objects[end], clips) { if err := p.ctx.Err(); err != nil { return nil, err } @@ -82,6 +83,7 @@ func (p *pdfImporter) sharedClipObjects(objects []GraphicObject) ([]GraphicObjec } if end-start < 2 { if output != nil { + mapped.recordOrigin(len(output), p.objects.origin(start)) output = append(output, objects[start]) } start = end @@ -89,6 +91,14 @@ func (p *pdfImporter) sharedClipObjects(objects []GraphicObject) ([]GraphicObjec } if output == nil { output = append(make([]GraphicObject, 0, len(objects)), objects[:start]...) + if p.objects != nil { + for _, run := range p.objects.sequences { + if run.start >= start { + break + } + mapped.sequences = append(mapped.sequences, run) + } + } } members := objects[start:end:end] for index := range members { @@ -98,6 +108,7 @@ func (p *pdfImporter) sharedClipObjects(objects []GraphicObject) ([]GraphicObjec if err != nil { return nil, err } + mapped.recordOrigin(len(output), p.objects.origin(start)) output = append(output, GraphicObject{Type: "CompositeObject", CompositeGraphicUnit: CompositeGraphicUnit{ Boundary: pdfBoundary(Box{W: p.pageWidth, H: p.pageHeight}), ResourceID: id, Clips: clips, }}) @@ -106,6 +117,11 @@ func (p *pdfImporter) sharedClipObjects(objects []GraphicObject) ([]GraphicObjec if output == nil { return objects, nil } + if p.objects != nil { + mapped.parts = [][]GraphicObject{output} + mapped.size = len(output) + *p.objects = mapped + } return output, nil } @@ -217,9 +233,6 @@ func (p *pdfImporter) clips(paths []pdfgo.Path, origin Box) (*Clips, error) { for _, mark := range path.Text { objects, ok := p.clipTexts[mark] if !ok { - if err := p.flushPath(); err != nil { - return nil, err - } count := p.objects.len() textCount := p.report.TextObjects if err := p.text(pdfgo.TextMark{Font: mark.Font, Glyphs: mark.Glyphs, Positions: mark.Positions, Matrix: mark.Matrix, Size: mark.Size, HorizontalScale: mark.HorizontalScale, Mode: 7, Clip: mark, Style: pdfgo.Style{Fill: pdfgo.Paint{Alpha: 1}}}); err != nil { diff --git a/ofdgo_pdf_composite.go b/ofdgo_pdf_composite.go index 16fa26c..84b828e 100644 --- a/ofdgo_pdf_composite.go +++ b/ofdgo_pdf_composite.go @@ -49,6 +49,7 @@ type pdfCompositor struct { inverse pdfgo.Matrix cache *pdfCompositeCache scratch *pdfCompositeScratch + origins *pdfOriginSet masks map[*pdfgo.SoftMask][]float64 meshes map[pdfMeshKey][]pdfShadingPixel gradients map[pdfGradientKey][]pdfShadingPixel @@ -628,9 +629,6 @@ func (p *pdfImporter) compositeObjects(nodes []pdfCompositeNode) error { if p.warning == nil { return &pdfgo.UnsupportedError{Feature: "page color-space compositing"} } - if err := p.flushPath(); err != nil { - return err - } if err := p.compositeRegion(p.compositeNodes, node, space, true); err != nil { return fmt.Errorf("composite graphic %d: %w", i+1, err) } @@ -638,7 +636,7 @@ func (p *pdfImporter) compositeObjects(nodes []pdfCompositeNode) error { p.compositeNodes = append(p.compositeNodes, node) p.transferBackdrop = p.transferBackdrop || transfer } - return p.flushPath() + return p.ctx.Err() } // opaque 检查图元是否无需与页面背景混合 @@ -900,6 +898,7 @@ func (p *pdfImporter) compositeRegion(backdrop []pdfCompositeNode, node pdfCompo var colorValues [4]float64 var colorRGB [3]float64 colorReady := false + var origins pdfOriginSet for y := 0; y < h; y += pdfCompositeTileSize { for x := 0; x < w; x += pdfCompositeTileSize { if err := p.ctx.Err(); err != nil { @@ -907,7 +906,7 @@ func (p *pdfImporter) compositeRegion(backdrop []pdfCompositeNode, node pdfCompo } runtime.Gosched() width, height := min(pdfCompositeTileSize, w-x), min(pdfCompositeTileSize, h-y) - c := pdfCompositor{importer: p, box: Box{X: box.X + float64(x)*step, Y: box.Y + float64(y)*step, W: float64(width) * step, H: float64(height) * step}, width: width, height: height, inverse: inverse, cache: cache, scratch: scratch, masks: map[*pdfgo.SoftMask][]float64{}} + c := pdfCompositor{importer: p, box: Box{X: box.X + float64(x)*step, Y: box.Y + float64(y)*step, W: float64(width) * step, H: float64(height) * step}, width: width, height: height, inverse: inverse, cache: cache, scratch: scratch, origins: &origins, masks: map[*pdfgo.SoftMask][]float64{}} if transfers != nil { c.transfers = transfers[:width*height] c.transferModel = device @@ -983,5 +982,5 @@ func (p *pdfImporter) compositeRegion(backdrop []pdfCompositeNode, node pdfCompo c.releaseMasks() } } - return p.appendRasterGroup(output, box, 1) + return p.appendRasterGroup(output, box, 1, origins.origin) } diff --git a/ofdgo_pdf_composite_draw.go b/ofdgo_pdf_composite_draw.go index fc1c767..c05318e 100644 --- a/ofdgo_pdf_composite_draw.go +++ b/ofdgo_pdf_composite_draw.go @@ -257,6 +257,7 @@ func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdf var previousValues [4]float64 var previousAlpha float64 composedReady := false + originRecorded := c.origins == nil for y := region.Min.Y; y < region.Max.Y; y++ { if err := c.importer.ctx.Err(); err != nil { return err @@ -385,6 +386,21 @@ func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdf if style.AlphaIsShape { shape, opacity = shape*opacity, 1 } + if !originRecorded && shape != 0 { + switch { + case node.path != nil: + c.origins.add(node.path.Sequence) + case node.text != nil: + origin := c.importer.textOrigin(*node.text) + c.origins.add(origin.sequence) + for _, sequence := range origin.related { + c.origins.add(sequence) + } + case node.image != nil: + c.origins.add(node.image.Sequence) + } + originRecorded = true + } if initial != nil { pixel := initial[index] if err := codecpdf.CompositePaintOver(&pixel, values, opacity, space, mode, compatible, marked); err != nil { diff --git a/ofdgo_pdf_composite_pattern.go b/ofdgo_pdf_composite_pattern.go index 16b5ae7..aefa140 100644 --- a/ofdgo_pdf_composite_pattern.go +++ b/ofdgo_pdf_composite_pattern.go @@ -123,7 +123,7 @@ func (c *pdfCompositor) tiling(paint pdfgo.Paint, backdrop []pdfCompositePixel, } } cell := pdfCompositor{importer: &pattern.local, box: Box{X: c.box.X + float64(left)*pixelStep - dx, Y: c.box.Y + float64(top)*pixelStep - dy, W: float64(width) * pixelStep, H: float64(height) * pixelStep}, - width: width, height: height, inverse: localInverse, cache: pattern.local.compositingCache(), scratch: c.scratch, masks: map[*pdfgo.SoftMask][]float64{}, softMask: c.softMask, channels: cellChannels} + width: width, height: height, inverse: localInverse, cache: pattern.local.compositingCache(), scratch: c.scratch, origins: c.origins, masks: map[*pdfgo.SoftMask][]float64{}, softMask: c.softMask, channels: cellChannels} err := cell.draw(pattern.nodes, pixels, space) cell.releaseMasks() if err != nil { diff --git a/ofdgo_pdf_form.go b/ofdgo_pdf_form.go index f314693..cc4418f 100644 --- a/ofdgo_pdf_form.go +++ b/ofdgo_pdf_form.go @@ -19,6 +19,8 @@ import ( "context" "crypto/sha256" "encoding/xml" + + "github.com/xiaoqidun/pdfgo" ) // pdfFormBufferLimit 限制表单编码暂存容量,不限制资源大小 @@ -74,6 +76,10 @@ func (c *pdfFormCache) reset() { // 入参: nodes 普通表单连续图元 // 返回: bool 是否已保存为复合对象, error 检查或转换错误 func (p *pdfImporter) compositeForm(nodes []pdfCompositeNode) (bool, error) { + sequence, same, err := p.formSequence(nodes) + if err != nil || !same { + return false, err + } if found, err := p.compositeHasTransfer(nodes, p.transferModel); err != nil || found { return false, err } @@ -86,9 +92,6 @@ func (p *pdfImporter) compositeForm(nodes []pdfCompositeNode) (bool, error) { return false, err } } - if err := p.flushPath(); err != nil { - return false, err - } parent := p.objects p.objects = nil defer func() { p.objects = parent }() @@ -98,9 +101,6 @@ func (p *pdfImporter) compositeForm(nodes []pdfCompositeNode) (bool, error) { return false, err } } - if err := p.flushPath(); err != nil { - return false, err - } if err := p.ctx.Err(); err != nil { return false, err } @@ -134,9 +134,55 @@ func (p *pdfImporter) compositeForm(nodes []pdfCompositeNode) (bool, error) { } } p.objects = parent - p.appendObject(GraphicObject{Type: "CompositeObject", CompositeGraphicUnit: CompositeGraphicUnit{ + p.appendSequenceObject(GraphicObject{Type: "CompositeObject", CompositeGraphicUnit: CompositeGraphicUnit{ Boundary: pdfBoundary(Box{W: p.pageWidth, H: p.pageHeight}), ResourceID: resource.id, - }}) + }}, sequence) parent = p.objects return true, nil } + +// formSequence 检查表单图元是否属于同一区段,不跨区段合并可独立引用的内容 +// 入参: nodes 表单图元 +// 返回: *pdfgo.ContentSequence 来源区段, bool 是否相同, error 取消错误 +func (p *pdfImporter) formSequence(nodes []pdfCompositeNode) (*pdfgo.ContentSequence, bool, error) { + var sequence *pdfgo.ContentSequence + seen := false + var visit func([]pdfCompositeNode) bool + visit = func(nodes []pdfCompositeNode) bool { + for _, node := range nodes { + if p.ctx.Err() != nil { + return false + } + var current *pdfgo.ContentSequence + switch { + case node.path != nil: + current = node.path.Sequence + case node.text != nil: + origin := p.textOrigin(*node.text) + if len(origin.related) != 0 { + return false + } + current = origin.sequence + case node.image != nil: + current = node.image.Sequence + case node.glyph != nil: + current = node.glyph.Sequence + default: + if !visit(node.children) { + return false + } + continue + } + if seen && sequence != current { + return false + } + sequence, seen = current, true + if !visit(node.children) { + return false + } + } + return true + } + same := visit(nodes) + return sequence, same, p.ctx.Err() +} diff --git a/ofdgo_pdf_geometry.go b/ofdgo_pdf_geometry.go index f8073d5..14f3454 100644 --- a/ofdgo_pdf_geometry.go +++ b/ofdgo_pdf_geometry.go @@ -21,7 +21,6 @@ import ( "fmt" "image/color" "math" - "reflect" "strconv" basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom" @@ -351,43 +350,13 @@ func (p *pdfImporter) pathDataContext(ctx context.Context, path pdfgo.Path, orig return string(data), nil } -// path 合并相同状态下的不透明描边,保留独立子路径与绘制顺序 +// path 保留每次路径绘制操作,重叠描边不合并为单次绘制 // 入参: mark PDF路径绘制信息 // 返回: error 错误信息 func (p *pdfImporter) path(mark pdfgo.PathMark) error { if mark.Style.BlendMode != "" && mark.Style.BlendMode != "Normal" && mark.Style.BlendMode != "Compatible" { return &pdfgo.UnsupportedError{Feature: "path blend mode " + string(mark.Style.BlendMode)} } - if !mark.Stroke || mark.Fill || mark.Style.Stroke.Alpha != 1 { - if err := p.flushPath(); err != nil { - return err - } - return p.appendPath(mark) - } - if p.pendingPath != nil && (p.pendingPath.StrokeMatrix != mark.StrokeMatrix || !reflect.DeepEqual(p.pendingPath.Style, mark.Style)) { - if err := p.flushPath(); err != nil { - return err - } - } - if p.pendingPath == nil { - p.pendingPath = &mark - } else { - p.pendingPath.Path.Segments = append(p.pendingPath.Path.Segments, mark.Path.Segments...) - } - if len(p.pendingPath.Path.Segments) >= 512 { - return p.flushPath() - } - return nil -} - -// flushPath 写入连续描边批次,保持后续图元的叠放位置 -// 返回: error 转换错误 -func (p *pdfImporter) flushPath() error { - if p.pendingPath == nil { - return nil - } - mark := *p.pendingPath - p.pendingPath = nil return p.appendPath(mark) } @@ -492,7 +461,7 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error { phase := mark.Style.DashPhase * scale object.DashOffset = &phase } - p.appendObject(GraphicObject{Type: "PathObject", PathObject: object}) + p.appendSequenceObject(GraphicObject{Type: "PathObject", PathObject: object}, mark.Sequence) p.report.PathObjects++ return nil } diff --git a/ofdgo_pdf_group.go b/ofdgo_pdf_group.go index 527c4ac..f3d7654 100644 --- a/ofdgo_pdf_group.go +++ b/ofdgo_pdf_group.go @@ -35,6 +35,7 @@ type pdfPaintRegion struct { paint pdfgo.Paint path GeometryPath intent pdfgo.Name + origin pdfObjectOrigin } // group 将单色图元的透明组转换为互不重叠的填充区域,保留矢量与组不透明度 @@ -47,9 +48,6 @@ func (p *pdfImporter) group(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) error if mark.BlendMode != "" && mark.BlendMode != "Normal" && mark.BlendMode != "Compatible" { return &pdfgo.UnsupportedError{Feature: "group blend mode " + string(mark.BlendMode)} } - if err := p.flushPath(); err != nil { - return err - } if mark.ColorSpace != nil && !mark.ColorSpace.SRGBEquivalent() { if mark.Alpha != 1 || mark.SoftMask != nil { return &pdfgo.UnsupportedError{Feature: "non-sRGB group color space"} @@ -72,10 +70,7 @@ func (p *pdfImporter) group(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) error } } if mark.Alpha == 1 { - if err := walk(visitor); err != nil { - return err - } - return p.flushPath() + return walk(visitor) } err := p.groupPaths(mark, walk, visitor) if errors.Is(err, errPDFGroupRaster) && p.warning != nil { @@ -140,6 +135,7 @@ func (p *pdfImporter) groupPaths(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) var combined GeometryPath var paint *pdfgo.Paint var intent pdfgo.Name + var origin pdfObjectOrigin var clips []pdfgo.Path opaque := true scale := math.Hypot(p.matrix[0], p.matrix[1]) @@ -185,13 +181,15 @@ func (p *pdfImporter) groupPaths(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) if paint == nil { paint, clips = &color, path.Style.Clips intent = path.Style.RenderingIntent + origin = pdfObjectOrigin{sequence: path.Sequence} } else if !reflect.DeepEqual(clips, path.Style.Clips) { return fmt.Errorf("%w: independently clipped group content", errPDFGroupRaster) } else if color.Alpha != 1 || !reflect.DeepEqual(*paint, color) || intent != path.Style.RenderingIntent { - regions = append(regions, pdfPaintRegion{*paint, combined, intent}) + regions = append(regions, pdfPaintRegion{*paint, combined, intent, origin}) combined = nil paint = &color intent = path.Style.RenderingIntent + origin = pdfObjectOrigin{sequence: path.Sequence} } outline := shape evenOdd := path.Path.EvenOdd @@ -242,7 +240,14 @@ func (p *pdfImporter) groupPaths(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) if err := p.ctx.Err(); err != nil { return err } - combined = append(combined, outline...) + if len(outline) != 0 { + if len(combined) == 0 { + origin = pdfObjectOrigin{sequence: path.Sequence} + } else { + origin = origin.merge(pdfObjectOrigin{sequence: path.Sequence}) + } + combined = append(combined, outline...) + } } return nil } @@ -252,10 +257,12 @@ func (p *pdfImporter) groupPaths(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) if err := p.ctx.Err(); err != nil { return err } - if len(combined) == 0 { + if len(combined) != 0 { + regions = append(regions, pdfPaintRegion{*paint, combined, intent, origin}) + } + if len(regions) == 0 { return nil } - regions = append(regions, pdfPaintRegion{*paint, combined, intent}) if !opaque { regions, err = pdfCompositeRegions(geometry, regions) if err != nil { @@ -310,7 +317,7 @@ func (p *pdfImporter) groupPaths(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) return err } object := PathObject{Boundary: pdfBoundary(box), AbbreviatedData: data, Fill: &fill, Stroke: &stroke, FillColor: color, Clips: objectClips} - p.appendObject(GraphicObject{Type: "PathObject", PathObject: object}) + p.appendOriginObject(GraphicObject{Type: "PathObject", PathObject: object}, region.origin) p.report.PathObjects++ } return nil @@ -320,7 +327,15 @@ func (p *pdfImporter) groupPaths(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) // 入参: mark 组状态, walk 内容访问函数 // 返回: error 转换或渲染错误 func (p *pdfImporter) rasterGroup(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) error) error { - img, box, err := p.renderGroup(func(local *pdfImporter) error { return walk(local.visitor()) }, nil) + var origin pdfObjectOrigin + img, box, err := p.renderGroup(func(local *pdfImporter) error { + if err := walk(local.visitor()); err != nil { + return err + } + var err error + origin, err = local.objects.combinedOrigin(local.ctx) + return err + }, nil) if err != nil || img == nil { return err } @@ -330,7 +345,7 @@ func (p *pdfImporter) rasterGroup(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) return err } } - return p.appendRasterGroup(img, box, mark.Alpha) + return p.appendRasterGroup(img, box, mark.Alpha, origin) } // visitor 绑定当前导入器的图元与透明组访问函数 @@ -385,16 +400,13 @@ func (p *pdfImporter) compileGroup(build func(*pdfImporter) error) (*RasterPage, if p.clipPaths == nil { p.clipPaths = &renderCache[[32]byte, pdfClipPath]{limit: 8 << 20} } - local := pdfImporter{ctx: p.ctx, reader: p.reader, editor: editor, renderer: p.renderer, matrix: p.matrix, pageBox: p.pageBox, pageWidth: p.pageWidth, pageHeight: p.pageHeight, rasterDPI: p.rasterDPI, warning: p.warning, rasterWarned: true, fontIDs: map[*pdfgo.Font]string{}, fonts: p.fonts, symbolFonts: p.symbolFonts, clipPaths: p.clipPaths} + local := pdfImporter{ctx: p.ctx, reader: p.reader, editor: editor, renderer: p.renderer, matrix: p.matrix, pageBox: p.pageBox, pageWidth: p.pageWidth, pageHeight: p.pageHeight, rasterDPI: p.rasterDPI, warning: p.warning, rasterWarned: true, fontIDs: map[*pdfgo.Font]string{}, fonts: p.fonts, symbolFonts: p.symbolFonts, clipPaths: p.clipPaths, textOrigins: p.textOrigins} if err := build(&local); err != nil { return nil, box, err } if err := p.ctx.Err(); err != nil { return nil, box, err } - if err := local.flushPath(); err != nil { - return nil, box, err - } if local.objects.len() == 0 { return nil, box, nil } @@ -492,9 +504,9 @@ func (p *pdfImporter) renderGroupSceneInto(source *RasterPage, box Box, target * } // appendRasterGroup 保存局部合成结果并记录输出精度 -// 入参: img 透明图像, box 页面区域, opacity 组透明度 +// 入参: img 透明图像, box 页面区域, opacity 组透明度, origin 来源区段 // 返回: error 保存错误 -func (p *pdfImporter) appendRasterGroup(img image.Image, box Box, opacity float64) error { +func (p *pdfImporter) appendRasterGroup(img image.Image, box Box, opacity float64, origin pdfObjectOrigin) error { var data bytes.Buffer if err := pdfgo.EncodePNG(p.ctx, &data, img); err != nil { return err @@ -508,7 +520,7 @@ func (p *pdfImporter) appendRasterGroup(img image.Image, box Box, opacity float6 } alpha := int(math.Round(opacity * 255)) object := ImageObject{ResourceID: id, Boundary: pdfBoundary(box), CTM: pdfNumbers(box.W, 0, 0, box.H, 0, 0), Alpha: &alpha} - p.appendObject(GraphicObject{Type: "ImageObject", ImageObject: object}) + p.appendOriginObject(GraphicObject{Type: "ImageObject", ImageObject: object}, origin) p.report.ImageObjects++ if !p.rasterWarned { p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF effect locally composited at %g DPI; other objects retained", p.rasterDPI)}) @@ -531,7 +543,7 @@ func pdfCompositeRegions(geometry GeometryBackend, regions []pdfPaintRegion) ([] return nil, err } if len(outside) != 0 { - next = append(next, pdfPaintRegion{backdrop.paint, outside, backdrop.intent}) + next = append(next, pdfPaintRegion{backdrop.paint, outside, backdrop.intent, backdrop.origin}) } overlap, err := geometry.Combine(backdrop.path, source.path, GeometryIntersect) if err != nil { @@ -542,7 +554,11 @@ func pdfCompositeRegions(geometry GeometryBackend, regions []pdfPaintRegion) ([] for i := range paint.RGB { paint.RGB[i] = (source.paint.RGB[i]*source.paint.Alpha + backdrop.paint.RGB[i]*backdrop.paint.Alpha*(1-source.paint.Alpha)) / paint.Alpha } - next = append(next, pdfPaintRegion{paint, overlap, ""}) + origin := source.origin + if source.paint.Alpha != 1 { + origin = backdrop.origin.merge(origin) + } + next = append(next, pdfPaintRegion{paint, overlap, "", origin}) } remaining, err = geometry.Combine(remaining, backdrop.path, GeometrySubtract) if err != nil { @@ -550,7 +566,7 @@ func pdfCompositeRegions(geometry GeometryBackend, regions []pdfPaintRegion) ([] } } if len(remaining) != 0 { - next = append(next, pdfPaintRegion{source.paint, remaining, source.intent}) + next = append(next, pdfPaintRegion{source.paint, remaining, source.intent, source.origin}) } result = next } diff --git a/ofdgo_pdf_image.go b/ofdgo_pdf_image.go index 470f8d7..9199f1f 100644 --- a/ofdgo_pdf_image.go +++ b/ofdgo_pdf_image.go @@ -69,9 +69,6 @@ func (p *pdfImporter) image(mark pdfgo.ImageMark) error { if mark.Style.BlendMode != "" && mark.Style.BlendMode != "Normal" && mark.Style.BlendMode != "Compatible" { return &pdfgo.UnsupportedError{Feature: "image blend mode " + string(mark.Style.BlendMode)} } - if err := p.flushPath(); err != nil { - return err - } if mark.Image.ImageMask && (mark.Style.Fill.Axial != nil || mark.Style.Fill.Radial != nil || mark.Style.Fill.Function != nil || mark.Style.Fill.Mesh != nil || mark.Style.Fill.Shading != nil) { if p.warning != nil { return p.compositeRegion(nil, pdfCompositeNode{image: &mark}, &pdfgo.ColorSpace{Model: "DeviceRGB"}, false) @@ -250,7 +247,7 @@ func (p *pdfImporter) appendImage(mark pdfgo.ImageMark, id string) error { return err } object := ImageObject{Boundary: pdfBoundary(box), ResourceID: id, CTM: pdfNumbers(m[0], m[1], m[2], m[3], m[4]-box.X, m[5]-box.Y), Alpha: &alpha, Clips: clips} - p.appendObject(GraphicObject{Type: "ImageObject", ImageObject: object}) + p.appendSequenceObject(GraphicObject{Type: "ImageObject", ImageObject: object}, mark.Sequence) p.report.ImageObjects++ return nil } diff --git a/ofdgo_pdf_metadata.go b/ofdgo_pdf_metadata.go new file mode 100644 index 0000000..9025279 --- /dev/null +++ b/ofdgo_pdf_metadata.go @@ -0,0 +1,352 @@ +// 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" + "encoding/base64" + "encoding/xml" + "fmt" + "maps" + "slices" + "strings" + "time" + + "github.com/xiaoqidun/pdfgo" +) + +// PDF元数据使用的RDF、Dublin Core及Adobe命名空间 +const ( + pdfMetadataRDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#" + pdfMetadataDC = "http://purl.org/dc/elements/1.1/" + pdfMetadataXMP = "http://ns.adobe.com/xap/1.0/" + pdfMetadataPDF = "http://ns.adobe.com/pdf/1.3/" + pdfMetadataXML = "adobe:ns:meta/" +) + +// PDF来源元数据的标引定义 +const ( + pdfMetadataTagType = "OFDGo.PDFMetadata" + pdfInformationTagType = "OFDGo.PDFInformation" + pdfInformationNamespace = "https://ofdgo.aite.me/ns/pdf/information/1" +) + +// pdfMetadataValue 保留字段是否声明及其来源,空文本不作为缺省值 +type pdfMetadataValue struct { + text string + xmp bool +} + +// documentMetadata 保留PDF元数据原文并映射文档描述,来源软件与扩展字段保存在独立标引中 +// 入参: ctx 取消上下文 +// 返回: error 读取、解析、日期或提交错误 +func (p *pdfImporter) documentMetadata(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + info, err := p.reader.Info() + if err != nil { + return err + } + values, original, err := p.informationMetadata(ctx, info) + if err != nil { + return err + } + stream, err := p.reader.Metadata() + if err != nil { + return err + } + 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 + } + xmp, err := pdfMetadataFields(ctx, root) + if err != nil { + return err + } + newer := false + if stamp, ok := xmp["MetadataDate"]; ok && stamp.text != "" && info.ModDate != "" { + date, _, zoned, err := pdfMetadataDate(stamp) + if err != nil { + return err + } + modified, err := pdfgo.ParseDate(info.ModDate) + if err != nil { + return err + } + newer = zoned && modified.After(date) + } + for key, value := range xmp { + if _, exists := values[key]; !newer || !exists { + values[key] = value + } + } + } + projected := p.editor.Info + for _, field := range []struct { + name string + text *string + }{{"Title", &projected.Title}, {"Author", &projected.Author}, {"Subject", &projected.Subject}} { + if value, ok := values[field.name]; ok && structureXMLText(value.text) { + *field.text = value.text + } + } + if value, ok := values["Keywords"]; ok && structureXMLText(value.text) { + projected.Keywords = Keywords{value.text} + } + for _, field := range []struct { + name string + text *string + }{{"CreationDate", &projected.CreationDate}, {"ModDate", &projected.ModDate}} { + if value, ok := values[field.name]; ok { + date, day, _, err := pdfMetadataDate(value) + if err != nil { + return fmt.Errorf("PDF metadata %s: %w", field.name, err) + } + *field.text = "" + if day { + *field.text = date.Format("2006-01-02") + } + } + } + if err := ctx.Err(); err != nil { + return err + } + p.editor.SetInfo(projected) + if root != nil { + if _, err := p.editor.addStructureXML(ctx, CustomTag{TypeID: pdfMetadataTagType, NameSpace: root.Namespace}, packet); err != nil { + return err + } + } + if len(original.Children) != 0 { + data, err := encodeStructureNode(original) + if err != nil { + return err + } + _, err = p.editor.addStructureXML(ctx, CustomTag{TypeID: pdfInformationTagType, NameSpace: pdfInformationNamespace}, data) + return err + } + return ctx.Err() +} + +// 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 + } + dict, ok := object.(pdfgo.Dictionary) + if !ok { + return nil, root, fmt.Errorf("invalid PDF information dictionary") + } + known := map[pdfgo.Name]string{ + "Title": info.Title, "Author": info.Author, "Subject": info.Subject, "Keywords": info.Keywords, + "Creator": info.Creator, "Producer": info.Producer, "CreationDate": info.CreationDate, "ModDate": info.ModDate, + "Trapped": string(info.Trapped), + } + for _, key := range slices.Sorted(maps.Keys(dict)) { + if err := ctx.Err(); err != nil { + return nil, root, err + } + value, err := p.reader.Resolve(dict[key]) + if err != nil { + return nil, root, err + } + if value == nil { + continue + } + text, standard := known[key] + if !standard { + text = info.Custom[key] + } else { + values[string(key)] = pdfMetadataValue{text: text} + } + name := string(key) + field := StructureNode{Name: "Field", Namespace: pdfInformationNamespace} + field.Attributes = append(field.Attributes, StructureAttribute{Name: "Name", Value: name}) + if !structureXMLText(name) { + field.Attributes[0].Value = base64.StdEncoding.EncodeToString([]byte(name)) + field.Attributes = append(field.Attributes, StructureAttribute{Name: "NameEncoding", Value: "base64"}) + } + if key == "Trapped" { + field.Attributes = append(field.Attributes, StructureAttribute{Name: "Type", Value: "name"}) + } + 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) + } + return values, root, nil +} + +// pdfMetadataFields 读取文档级RDF描述,日期及PDF关键词不与其他同名扩展混用 +// 入参: ctx 取消上下文, root 元数据根节点 +// 返回: map[string]pdfMetadataValue 已声明的对应字段, error 取消错误 +func pdfMetadataFields(ctx context.Context, root *StructureNode) (map[string]pdfMetadataValue, error) { + result := make(map[string]pdfMetadataValue) + var rdf *StructureNode + if root.Namespace == pdfMetadataRDF && root.Name == "RDF" { + rdf = root + } else if root.Namespace == pdfMetadataXML && (root.Name == "xmpmeta" || root.Name == "xapmeta") { + for i := range root.Children { + if err := ctx.Err(); err != nil { + return nil, err + } + child := &root.Children[i] + if child.Namespace == pdfMetadataRDF && child.Name == "RDF" { + rdf = child + break + } + } + } + if rdf == nil { + return result, ctx.Err() + } + fields := map[xml.Name]string{ + {Space: pdfMetadataDC, Local: "title"}: "Title", {Space: pdfMetadataDC, Local: "creator"}: "Author", + {Space: pdfMetadataDC, Local: "description"}: "Subject", {Space: pdfMetadataPDF, Local: "Keywords"}: "Keywords", + {Space: pdfMetadataXMP, Local: "CreateDate"}: "CreationDate", {Space: pdfMetadataXMP, Local: "ModifyDate"}: "ModDate", + {Space: pdfMetadataXMP, Local: "MetadataDate"}: "MetadataDate", + } + for _, description := range rdf.Children { + if err := ctx.Err(); err != nil { + return nil, err + } + if description.Namespace != pdfMetadataRDF || description.Name != "Description" { + continue + } + for _, attr := range description.Attributes { + if key, ok := fields[xml.Name{Space: attr.Namespace, Local: attr.Name}]; ok && attr.Namespace != pdfMetadataDC { + result[key] = pdfMetadataValue{text: attr.Value, xmp: true} + } + } + for _, property := range description.Children { + if err := ctx.Err(); err != nil { + return nil, err + } + key, ok := fields[xml.Name{Space: property.Namespace, Local: property.Name}] + if !ok { + continue + } + arrayValue := property.Namespace == pdfMetadataDC + property = pdfMetadataProperty(property) + if arrayValue { + kind := "Alt" + if key == "Author" { + kind = "Seq" + } + found := false + for _, array := range property.Children { + if array.Namespace == pdfMetadataRDF && array.Name == kind && len(array.Children) != 0 { + item := array.Children[0] + if item.Namespace == pdfMetadataRDF && item.Name == "li" { + property = item + found = true + } + break + } + } + if !found { + continue + } + } + if text, ok := pdfMetadataText(property); ok { + result[key] = pdfMetadataValue{text: text, xmp: true} + } + } + } + return result, ctx.Err() +} + +// pdfMetadataProperty 提取带限定符的rdf:value,保留数组及结构边界 +// 入参: node 属性节点 +// 返回: StructureNode 属性值节点 +func pdfMetadataProperty(node StructureNode) StructureNode { + for { + if len(node.Children) == 1 && node.Children[0].Namespace == pdfMetadataRDF && node.Children[0].Name == "Description" { + node = node.Children[0] + continue + } + for _, attr := range node.Attributes { + if attr.Namespace == pdfMetadataRDF && attr.Name == "value" { + return StructureNode{Text: attr.Value} + } + } + found := false + for _, child := range node.Children { + if child.Namespace == pdfMetadataRDF && child.Name == "value" { + node, found = child, true + break + } + } + if !found { + return node + } + } +} + +// pdfMetadataText 读取简单值,不拼接结构或数组文本,也不将资源URI当作文本 +// 入参: node 属性节点 +// 返回: string 文本, bool 是否为支持的简单值 +func pdfMetadataText(node StructureNode) (string, bool) { + node = pdfMetadataProperty(node) + for _, attr := range node.Attributes { + if attr.Namespace == pdfMetadataRDF && (attr.Name == "resource" || attr.Name == "nodeID" || attr.Name == "parseType") { + return "", false + } + } + return node.Text, len(node.Children) == 0 +} + +// pdfMetadataDate 保留当地日历日期,PDF省略时区按GMT,XMP省略日或时区时不推算 +// 入参: value 来源日期 +// 返回: time.Time 解析值, bool 是否精确到日, bool 是否可比较绝对时间, error 日期错误 +func pdfMetadataDate(value pdfMetadataValue) (time.Time, bool, bool, error) { + if value.text == "" { + return time.Time{}, false, false, nil + } + if !value.xmp { + date, err := pdfgo.ParseDate(value.text) + return date, err == nil && date.Year() > 0, err == nil, err + } + if !strings.Contains(value.text, ",") { + for _, layout := range []string{time.RFC3339Nano, "2006-01-02T15:04Z07:00", "2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02", "2006-01", "2006"} { + if date, err := time.Parse(layout, value.text); err == nil { + zoned := strings.Contains(layout, "Z07:00") + if zoned && !strings.HasSuffix(value.text, "Z") { + zone := value.text[len(value.text)-6:] + if zone[1:3] > "23" || zone[4:] > "59" { + return time.Time{}, false, false, fmt.Errorf("invalid XMP time zone") + } + } + return date, len(value.text) >= 10 && date.Year() > 0, zoned, nil + } + } + } + return time.Time{}, false, false, fmt.Errorf("invalid XMP date") +} diff --git a/ofdgo_pdf_pattern.go b/ofdgo_pdf_pattern.go index 276623d..85bfea9 100644 --- a/ofdgo_pdf_pattern.go +++ b/ofdgo_pdf_pattern.go @@ -35,6 +35,12 @@ type pdfPatternCellKey struct { space *pdfgo.ColorSpace } +// pdfPatternCell 保存图案快照及单元内部的来源区段 +type pdfPatternCell struct { + pattern *Pattern + origin pdfObjectOrigin +} + // pdfPatternInstanceRange 收缩已知路径及图片的实例范围,未知边界保留完整范围 // 入参: columns 横向实例数, rows 纵向实例数, bounds 对象边界, width 单元宽, height 单元高, xstep 横向步距, ystep 纵向步距 // 返回: int 左界, int 右界, int 上界, int 下界 @@ -63,7 +69,7 @@ func (p *pdfImporter) tilingPattern(source *pdfgo.TilingPattern, base pdfgo.Pain return p.buildTilingPattern(source, base) } if p.patternCells == nil { - p.patternCells = &renderCache[pdfPatternCellKey, *Pattern]{limit: pdfPatternCellLimit} + p.patternCells = &renderCache[pdfPatternCellKey, pdfPatternCell]{limit: pdfPatternCellLimit} } key := pdfPatternCellKey{source: source, matrix: p.matrix, dpi: p.rasterDPI, editor: p.editor, space: p.compositeSpace} if source.PaintType == 2 { @@ -72,7 +78,11 @@ func (p *pdfImporter) tilingPattern(source *pdfgo.TilingPattern, base pdfgo.Pain key.base = base } if cached, ok := p.patternCells.get(key); ok { - return cloneEditorData(cached), p.ctx.Err() + pattern := cloneEditorData(cached.pattern) + if cached.origin.sequence != nil || len(cached.origin.related) != 0 { + p.patternOrigins[pattern] = cached.origin + } + return pattern, p.ctx.Err() } pattern, err := p.buildTilingPattern(source, base) if err != nil { @@ -82,8 +92,9 @@ func (p *pdfImporter) tilingPattern(source *pdfgo.TilingPattern, base pdfgo.Pain return nil, err } cost := pdfPatternCellCost(reflect.ValueOf(pattern), p.patternCells.limit, 0) + cost += int(reflect.TypeFor[pdfPatternCell]().Size()) + len(p.patternOrigins[pattern].related)*int(reflect.TypeFor[*pdfgo.ContentSequence]().Size()) if cost <= p.patternCells.limit { - p.patternCells.put(key, cloneEditorData(pattern), cost) + p.patternCells.put(key, pdfPatternCell{pattern: cloneEditorData(pattern), origin: p.patternOrigins[pattern]}, cost) } return pattern, p.ctx.Err() } @@ -143,6 +154,9 @@ func (p *pdfImporter) buildTilingPattern(source *pdfgo.TilingPattern, base pdfgo localToPDF := pdfgo.Matrix{1 / unit, 0, 0, -1 / unit, box.XMin, box.YMax} m := p.matrix.Mul(source.Matrix).Mul(localToPDF) pattern := &Pattern{Width: math.Min(width, xstep) * unit, Height: math.Min(height, ystep) * unit, XStep: xstep * unit, YStep: ystep * unit, RelativeTo: "Page", CTM: pdfNumbers(m[:]...)} + if p.patternOrigins == nil { + p.patternOrigins = make(map[*Pattern]pdfObjectOrigin) + } cell := *p cell.matrix = pdfgo.Matrix{unit, 0, 0, -unit, -box.XMin * unit, box.YMax * unit} cell.pageWidth, cell.pageHeight = width*unit, height*unit @@ -150,12 +164,21 @@ func (p *pdfImporter) buildTilingPattern(source *pdfgo.TilingPattern, base pdfgo if !finite(cell.rasterDPI) || cell.rasterDPI < 0 { return nil, fmt.Errorf("invalid PDF tiling pattern raster scale") } - cell.objects, cell.pendingPath = nil, nil - if err := source.Walk(p.ctx, base, cell.visitor()); err != nil { + cell.objects = nil + nodes, err := cell.collectCompositeNodes(func(visitor pdfgo.Visitor) error { + return source.Walk(p.ctx, base, visitor) + }, false) + if err != nil { return nil, err } - if err := cell.flushPath(); err != nil { - return nil, err + visitor := cell.visitor() + for _, node := range nodes { + if err := p.ctx.Err(); err != nil { + return nil, err + } + if err := node.emit(visitor); err != nil { + return nil, err + } } objects, err := cell.objects.data(p.ctx) if err != nil { @@ -205,7 +228,7 @@ func (p *pdfImporter) buildTilingPattern(source *pdfgo.TilingPattern, base pdfgo if err != nil { return nil, err } - expanded.append(translated) + expanded.appendOrigin(translated, cell.objects.origin(index)) } } } @@ -213,6 +236,14 @@ func (p *pdfImporter) buildTilingPattern(source *pdfgo.TilingPattern, base pdfgo if err != nil { return nil, err } + cell.objects = &expanded + } + origin, err := cell.objects.combinedOrigin(p.ctx) + if err != nil { + return nil, err + } + if origin.sequence != nil || len(origin.related) != 0 { + p.patternOrigins[pattern] = origin } return pattern, nil } diff --git a/ofdgo_pdf_structure.go b/ofdgo_pdf_structure.go new file mode 100644 index 0000000..8768345 --- /dev/null +++ b/ofdgo_pdf_structure.go @@ -0,0 +1,879 @@ +// 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" + "encoding/base64" + "fmt" + "maps" + "math" + "slices" + "strconv" + "strings" + + "github.com/xiaoqidun/pdfgo" +) + +// PDF逻辑结构标引使用独立命名空间,图元引用沿用ObjectRef语义 +const ( + pdfStructureTagType = "OFDGo.PDFStructure" + pdfStructureNamespace = "https://ofdgo.aite.me/ns/pdf/structure/1" +) + +// pdfStructureBindings 保存来源区段与最终页面图元的关联,不记录临时渲染对象 +type pdfStructureBindings struct { + parents map[*pdfgo.Reader]map[[2]int64]pdfgo.Reference + prepared map[*pdfgo.Reader]bool + readers []*pdfgo.Reader + marked map[pdfStructureContent]bool + sequences map[*pdfgo.ContentSequence][]StructureReference + order []*pdfgo.ContentSequence + contents []*pdfgo.ContentSequence + objects map[pdfStructureObject][]StructureReference +} + +// pdfStructureObject 隔离不同文件及页面的整对象引用 +type pdfStructureObject struct { + reader *pdfgo.Reader + page pdfgo.Reference + object pdfgo.Reference +} + +// pdfStructureObjectBinding 保存尚未提交的注解外观引用 +type pdfStructureObjectBinding struct { + source pdfStructureObject + ids []string + content []pdfStructureBinding +} + +// pdfStructureContent 隔离不同文件、页面及内容流中的标记编号 +type pdfStructureContent struct { + reader *pdfgo.Reader + page pdfgo.Reference + stream *pdfgo.Stream + mcid int64 +} + +// pdfStructureReferences 保存内容引用与已提交图元的索引 +type pdfStructureReferences map[pdfStructureContent][]StructureReference + +// pdfStructureReferenceSet 按首次出现顺序归并页内图元,单页时不建立页索引 +type pdfStructureReferenceSet struct { + references []StructureReference + pages map[string]int + seen map[[2]string]bool +} + +// pdfStructureBinding 保存一个来源区段对应的连续对象位置 +type pdfStructureBinding struct { + sequence *pdfgo.ContentSequence + start int + end int +} + +// pdfStructureValues 保存属性值类型及间接对象共享关系 +type pdfStructureValues struct { + ctx context.Context + reader *pdfgo.Reader + seen map[pdfgo.Reference]bool + elements map[pdfgo.Reference]bool + objects []StructureNode +} + +// prepareStructure 校验结构内容关联并登记引用区段,不提前写入标引 +// 入参: ctx 取消上下文 +// 返回: error 结构或内容容器错误 +func (p *pdfImporter) prepareStructure(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + if p.structureBindings.prepared[p.reader] { + return nil + } + tree, err := p.reader.StructureTree() + if err != nil { + return err + } + if tree == nil { + if p.structureBindings.prepared == nil { + p.structureBindings.prepared = make(map[*pdfgo.Reader]bool) + } + p.structureBindings.prepared[p.reader] = true + return ctx.Err() + } + parents, err := p.structureParents(ctx) + if err != nil { + return err + } + marked := make(map[pdfStructureContent]bool) + if err := p.reader.WalkStructure(ctx, func(_ int, item pdfgo.StructureItem) error { + if item.Type != "MCR" && item.Type != "OBJR" { + return nil + } + content, err := p.reader.StructureContent(item) + if err != nil { + return err + } + mcid := int64(-1) + if item.Type == "MCR" { + mcid = item.MCID + marked[pdfStructureContent{reader: p.reader, page: item.Page, stream: content.Stream, mcid: item.MCID}] = true + } + if parent, ok := parents[[2]int64{content.ParentKey, mcid}]; !ok || parent != item.Parent { + return fmt.Errorf("PDF structure content parent mismatch") + } + return nil + }); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + b := p.structureBindings + if b.parents == nil { + b.parents = make(map[*pdfgo.Reader]map[[2]int64]pdfgo.Reference) + } + if b.prepared == nil { + b.prepared = make(map[*pdfgo.Reader]bool) + } + if b.marked == nil { + b.marked = make(map[pdfStructureContent]bool) + } + b.parents[p.reader], b.prepared[p.reader] = parents, true + maps.Copy(b.marked, marked) + b.readers = append(b.readers, p.reader) + return nil +} + +// prepareSourceStructure 使用来源阅读器校验引用文档,共享最终图元索引 +// 入参: ctx 取消上下文, reader 来源阅读器 +// 返回: error 结构或取消错误 +func (p *pdfImporter) prepareSourceStructure(ctx context.Context, reader *pdfgo.Reader) error { + if p.structureBindings.prepared[reader] { + return ctx.Err() + } + source := *p + source.reader = reader + return source.prepareStructure(ctx) +} + +// structureParents 复用预检父树索引,未预检时读取当前文档 +// 入参: ctx 取消上下文 +// 返回: map[[2]int64]pdfgo.Reference 父树键及区段编号对应的元素, error 解析或取消错误 +func (p *pdfImporter) structureParents(ctx context.Context) (map[[2]int64]pdfgo.Reference, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if parents := p.structureBindings.parents[p.reader]; parents != nil { + return parents, nil + } + parents := make(map[[2]int64]pdfgo.Reference) + if err := p.reader.WalkStructureParents(ctx, func(parent pdfgo.StructureParent) error { + parents[[2]int64{parent.Key, parent.MCID}] = parent.Element + return nil + }); err != nil { + return nil, err + } + return parents, nil +} + +// documentStructures 分别保存主文档与引用文档的结构,不合并来源对象编号 +// 入参: ctx 取消上下文 +// 返回: error 结构或提交错误 +func (p *pdfImporter) documentStructures(ctx context.Context) error { + for _, sequence := range p.structureBindings.order { + if err := p.prepareSourceStructure(ctx, sequence.Reader); err != nil { + return err + } + } + for _, reader := range p.structureBindings.readers { + source := *p + source.reader = reader + if err := source.documentStructure(ctx); err != nil { + return err + } + } + return ctx.Err() +} + +// documentStructure 写出逻辑结构层级、文本属性及已提交的内容引用 +// 入参: ctx 取消上下文 +// 返回: error 解析、未支持的结构属性或提交错误 +func (p *pdfImporter) documentStructure(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + tree, err := p.reader.StructureTree() + if err != nil || tree == nil { + return err + } + refs, err := p.structureBindings.contentReferences(ctx) + if err != nil { + return err + } + objects, err := p.structureBindings.objectReferences(ctx) + if err != nil { + return err + } + parents, err := p.structureParents(ctx) + if err != nil { + return err + } + root := StructureNode{Name: "Structure", Namespace: pdfStructureNamespace} + values := pdfStructureValues{ctx: ctx, reader: p.reader, seen: make(map[pdfgo.Reference]bool), elements: make(map[pdfgo.Reference]bool)} + if tree.Reference != (pdfgo.Reference{}) { + values.elements[tree.Reference] = true + root.Attributes = pdfStructureSource(tree.Reference) + } + if err := p.reader.WalkStructure(ctx, func(_ int, item pdfgo.StructureItem) error { + if item.Type == "StructElem" && item.Reference != (pdfgo.Reference{}) { + values.elements[item.Reference] = true + } + return nil + }); err != nil { + return err + } + properties, err := p.structureBindings.contentProperties(&values) + if err != nil { + return err + } + for _, key := range []pdfgo.Name{"RoleMap", "ClassMap"} { + if value := tree.Dictionary[key]; value != nil { + node, err := values.encode(value, 0) + if err != nil { + return err + } + root.Children = append(root.Children, StructureNode{Name: string(key), Namespace: pdfStructureNamespace, Children: []StructureNode{node}}) + } + } + extensions, err := values.extensions(tree.Dictionary, []pdfgo.Name{"Type", "K", "ParentTree", "ParentTreeNextKey", "RoleMap", "ClassMap", "IDTree"}) + if err != nil { + return err + } + root.Children = append(root.Children, extensions...) + stack := []*StructureNode{&root} + ids := make(map[string]pdfgo.Reference) + err = p.reader.WalkStructure(ctx, func(depth int, item pdfgo.StructureItem) error { + if depth >= len(stack) { + return fmt.Errorf("invalid PDF structure depth") + } + stack = stack[:depth+1] + parent := stack[depth] + node := StructureNode{Name: string(item.Type), Namespace: pdfStructureNamespace} + var content pdfgo.StructureContent + if item.Type == "MCR" || item.Type == "OBJR" { + content, err = p.reader.StructureContent(item) + if err != nil { + return err + } + mcid := int64(-1) + if item.Type == "MCR" { + mcid = item.MCID + } + if parent, ok := parents[[2]int64{content.ParentKey, mcid}]; !ok || parent != item.Parent { + return fmt.Errorf("PDF structure content parent mismatch") + } + } + switch item.Type { + case "StructElem": + extensions, err := values.extensions(item.Dictionary, []pdfgo.Name{"Type", "S", "P", "K", "Pg", "R", "ID", "T", "Lang", "Alt", "ActualText", "E", "A", "C"}) + if err != nil { + return err + } + node.Children = append(node.Children, extensions...) + if item.Reference != (pdfgo.Reference{}) { + node.Attributes = pdfStructureSource(item.Reference) + } + if _, err := p.reader.StructureAttributes(ctx, item); err != nil { + return err + } + for _, key := range []pdfgo.Name{"A", "C"} { + if value := item.Dictionary[key]; value != nil { + child, err := values.encode(value, 0) + if err != nil { + return err + } + node.Children = append(node.Children, StructureNode{Name: string(key), Namespace: pdfStructureNamespace, Children: []StructureNode{child}}) + } + } + node.Attributes = append(node.Attributes, pdfStructureAttribute("Tag", string(item.Tag))...) + node.Attributes = append(node.Attributes, StructureAttribute{Name: "Revision", Value: strconv.FormatInt(item.Revision, 10)}) + for _, field := range []pdfgo.Name{"ID", "T", "Lang", "Alt", "ActualText", "E"} { + value, err := p.reader.Resolve(item.Dictionary[field]) + if err != nil { + return err + } + if value == nil { + continue + } + text, ok := value.(pdfgo.String) + if !ok { + return fmt.Errorf("invalid PDF structure text: %s", field) + } + decoded := string(text) + if field == "ID" { + if _, exists := ids[decoded]; exists { + return fmt.Errorf("duplicate PDF structure identifier") + } + ids[decoded] = item.Reference + } else { + decoded, err = pdfgo.DecodeTextString(text) + if err != nil { + return err + } + } + node.Attributes = append(node.Attributes, pdfStructureAttribute(string(field), decoded)...) + } + case "MCR": + extensions, err := values.extensions(item.Dictionary, []pdfgo.Name{"Type", "Pg", "Stm", "StmOwn", "MCID"}) + if err != nil { + return err + } + node.Children = append(node.Children, extensions...) + targets, err := refs.targets(ctx, p.reader, item) + if err != nil { + return err + } + node.Attributes = []StructureAttribute{{Name: "MCID", Value: strconv.FormatInt(item.MCID, 10)}} + key := pdfStructureContent{reader: p.reader, page: item.Page, stream: content.Stream, mcid: item.MCID} + if property, ok := properties[key]; ok { + node.Children = append(node.Children, property) + } + for _, target := range targets { + node.Children = append(node.Children, StructureNode{Name: "ObjectRef", Namespace: pdfStructureNamespace, Attributes: []StructureAttribute{{Name: "PageRef", Value: target.PageID}}, Text: strings.Join(target.ObjectIDs, ",")}) + } + case "OBJR": + extensions, err := values.extensions(item.Dictionary, []pdfgo.Name{"Type", "Pg", "Obj"}) + if err != nil { + return err + } + node.Children = append(node.Children, extensions...) + targets, exists := objects[pdfStructureObject{reader: p.reader, page: item.Page, object: item.Target}] + if !exists { + kind, err := p.reader.Resolve(content.Dictionary["Type"]) + if err != nil { + return err + } + subtype, err := p.reader.Resolve(content.Dictionary["Subtype"]) + if err != nil { + return err + } + name, _ := subtype.(pdfgo.Name) + if content.Stream != nil { + if kind != nil && kind != pdfgo.Name("XObject") || name != "Form" && name != "Image" { + return fmt.Errorf("unsupported PDF structure object target") + } + } else if kind != nil && kind != pdfgo.Name("Annot") || !(pdfgo.Annotation{Subtype: name}).IsStandard() { + return fmt.Errorf("unsupported PDF structure object target") + } + } + for _, target := range targets { + if len(target.ObjectIDs) != 0 { + node.Children = append(node.Children, StructureNode{Name: "ObjectRef", Namespace: pdfStructureNamespace, Attributes: []StructureAttribute{{Name: "PageRef", Value: target.PageID}}, Text: strings.Join(target.ObjectIDs, ",")}) + } + } + default: + return fmt.Errorf("unsupported PDF structure item: %s", item.Type) + } + parent.Children = append(parent.Children, node) + stack = append(stack, &parent.Children[len(parent.Children)-1]) + return ctx.Err() + }) + if err != nil { + return err + } + if err := p.reader.WalkStructureIDs(ctx, func(id string, item pdfgo.StructureItem) error { + ref, exists := ids[id] + if !exists || ref != item.Reference { + return fmt.Errorf("PDF structure identifier target is outside the hierarchy") + } + delete(ids, id) + return nil + }); err != nil { + return err + } + if len(ids) != 0 { + return fmt.Errorf("missing PDF structure identifier index") + } + if len(values.objects) != 0 { + root.Children = append(root.Children, StructureNode{Name: "SourceObjects", Namespace: pdfStructureNamespace, Children: values.objects}) + } + data, err := encodeStructureNodeContext(ctx, root) + if err != nil { + return err + } + _, err = p.editor.addStructureXML(ctx, CustomTag{TypeID: pdfStructureTagType, NameSpace: pdfStructureNamespace}, data) + return err +} + +// pdfStructureSource 保存源结构标识,不与OFD图元标识混用 +// 入参: ref 来源引用 +// 返回: []StructureAttribute 对象编号及代数 +func pdfStructureSource(ref pdfgo.Reference) []StructureAttribute { + return []StructureAttribute{{Name: "SourceNumber", Value: strconv.FormatInt(ref.Number, 10)}, {Name: "SourceGeneration", Value: strconv.FormatInt(ref.Generation, 10)}} +} + +// extensions 保留未投影字段,字段名不作为XML元素名称 +// 入参: dictionary 来源字典, known 已投影字段 +// 返回: []StructureNode 扩展字段, error 读取或取消错误 +func (v *pdfStructureValues) extensions(dictionary pdfgo.Dictionary, known []pdfgo.Name) ([]StructureNode, error) { + var result []StructureNode + for _, key := range slices.Sorted(maps.Keys(dictionary)) { + if err := v.ctx.Err(); err != nil { + return nil, err + } + if slices.Contains(known, key) { + continue + } + value, err := v.encode(dictionary[key], 0) + if err != nil { + return nil, err + } + result = append(result, StructureNode{Name: "Extension", Namespace: pdfStructureNamespace, Attributes: pdfStructureAttribute("Key", string(key)), Children: []StructureNode{value}}) + } + return result, v.ctx.Err() +} + +// encode 按原始类型保存属性对象,间接对象仅展开一次,字符串保留原始字节 +// 入参: object 属性对象, depth 当前值层级 +// 返回: StructureNode 类型化属性值, error 读取、层级或取消错误 +func (v *pdfStructureValues) encode(object pdfgo.Object, depth int) (StructureNode, error) { + node := StructureNode{Namespace: pdfStructureNamespace} + if err := v.ctx.Err(); err != nil { + return node, err + } + if depth > 128 { + return node, fmt.Errorf("PDF structure attribute limit exceeded") + } + switch value := object.(type) { + case nil: + node.Name = "Null" + case pdfgo.Boolean: + node.Name, node.Text = "Boolean", strconv.FormatBool(bool(value)) + case pdfgo.Integer: + node.Name, node.Text = "Integer", strconv.FormatInt(int64(value), 10) + case pdfgo.Real: + if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) { + return node, fmt.Errorf("invalid PDF structure real") + } + node.Name, node.Text = "Real", strconv.FormatFloat(float64(value), 'g', -1, 64) + case pdfgo.Name: + node.Name = "Name" + node.Attributes = pdfStructureAttribute("Value", string(value)) + case pdfgo.String: + node.Name, node.Text = "String", base64.StdEncoding.EncodeToString(value) + node.Attributes = []StructureAttribute{{Name: "Encoding", Value: "base64"}} + case pdfgo.Reference: + node.Name = "Reference" + node.Attributes = []StructureAttribute{{Name: "Number", Value: strconv.FormatInt(value.Number, 10)}, {Name: "Generation", Value: strconv.FormatInt(value.Generation, 10)}} + if v.elements[value] { + node.Name = "ElementReference" + return node, v.ctx.Err() + } + if !v.seen[value] { + v.seen[value] = true + resolved, err := v.reader.Resolve(value) + if err != nil { + return node, err + } + child, err := v.encode(resolved, depth+1) + if err != nil { + return node, err + } + v.objects = append(v.objects, StructureNode{Name: "Indirect", Namespace: pdfStructureNamespace, Attributes: slices.Clone(node.Attributes), Children: []StructureNode{child}}) + } + case pdfgo.Array: + node.Name = "Array" + for _, object := range value { + child, err := v.encode(object, depth+1) + if err != nil { + return node, err + } + node.Children = append(node.Children, child) + } + case pdfgo.Dictionary: + node.Name = "Dictionary" + for _, key := range slices.Sorted(maps.Keys(value)) { + child, err := v.encode(value[key], depth+1) + if err != nil { + return node, err + } + node.Children = append(node.Children, StructureNode{Name: "Entry", Namespace: pdfStructureNamespace, Attributes: pdfStructureAttribute("Key", string(key)), Children: []StructureNode{child}}) + } + case *pdfgo.Stream: + node.Name = "Stream" + dictionary, err := v.encode(value.Dictionary, depth+1) + if err != nil { + return node, err + } + 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)}} + default: + return node, fmt.Errorf("invalid PDF structure attribute value") + } + return node, v.ctx.Err() +} + +// pdfStructureAttribute 保留XML不可表达的源字段,不替换原始字符 +// 入参: name 属性名, value 属性值 +// 返回: []StructureAttribute 属性及可选的编码声明 +func pdfStructureAttribute(name, value string) []StructureAttribute { + if structureXMLText(value) { + return []StructureAttribute{{Name: name, Value: value}} + } + return []StructureAttribute{{Name: name, Value: base64.StdEncoding.EncodeToString([]byte(value))}, {Name: name + "Encoding", Value: "base64"}} +} + +// targets 解析结构树中的内容流引用,不将未绘制内容绑定到其他同号区段 +// 入参: ctx 取消上下文, reader 来源阅读器, item 标记内容引用 +// 返回: []StructureReference 独立的目标引用, error 来源或取消错误 +func (refs pdfStructureReferences) targets(ctx context.Context, reader *pdfgo.Reader, item pdfgo.StructureItem) ([]StructureReference, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if item.Type != "MCR" || item.MCID < 0 || item.Page == (pdfgo.Reference{}) { + return nil, fmt.Errorf("invalid PDF marked content reference") + } + key := pdfStructureContent{reader: reader, page: item.Page, mcid: item.MCID} + if item.Stream != (pdfgo.Reference{}) { + value, err := reader.Resolve(item.Stream) + if err != nil { + return nil, err + } + var ok bool + key.stream, ok = value.(*pdfgo.Stream) + if !ok { + return nil, fmt.Errorf("invalid PDF marked content stream") + } + } + var result []StructureReference + for _, ref := range refs[key] { + if err := ctx.Err(); err != nil { + return nil, err + } + copy := StructureReference{PageID: ref.PageID} + for _, id := range ref.ObjectIDs { + if err := ctx.Err(); err != nil { + return nil, err + } + copy.ObjectIDs = append(copy.ObjectIDs, id) + } + result = append(result, copy) + } + return result, ctx.Err() +} + +// objectReferences 汇总注解及XObject调用对应的已提交图元 +// 入参: ctx 取消上下文 +// 返回: map[pdfStructureObject][]StructureReference 对象引用索引, error 来源或取消错误 +func (b *pdfStructureBindings) objectReferences(ctx context.Context) (map[pdfStructureObject][]StructureReference, error) { + result := make(map[pdfStructureObject][]StructureReference) + sets := make(map[pdfStructureObject]*pdfStructureReferenceSet) + appendReferences := func(key pdfStructureObject, sources []StructureReference) error { + set := sets[key] + if set == nil { + set = &pdfStructureReferenceSet{} + sets[key] = set + } + if err := set.append(ctx, sources); err != nil { + return err + } + result[key] = set.references + return nil + } + for key, refs := range b.objects { + if err := appendReferences(key, refs); err != nil { + return nil, err + } + } + for _, sequence := range b.order { + if err := ctx.Err(); err != nil { + return nil, err + } + if sequence.Object == (pdfgo.Reference{}) { + continue + } + if sequence.Reader == nil || sequence.Page == nil { + return nil, fmt.Errorf("missing PDF object source") + } + key := pdfStructureObject{reader: sequence.Reader, page: sequence.Page.Reference, object: sequence.Object} + if err := appendReferences(key, b.sequences[sequence]); err != nil { + return nil, err + } + } + return result, ctx.Err() +} + +// observe 保存已读取的标记区段,不登记尚未提交的图元 +// 入参: sequence 来源区段 +func (b *pdfStructureBindings) observe(sequence *pdfgo.ContentSequence) { + if sequence != nil && sequence.Properties["MCID"] != nil { + b.contents = append(b.contents, sequence) + } +} + +// pdfStructureContentKey 解析来源区段中的MCID,空值表示非结构标记 +// 入参: sequence 来源区段 +// 返回: pdfStructureContent 来源键, bool 是否具有MCID, error 属性或来源错误 +func pdfStructureContentKey(sequence *pdfgo.ContentSequence) (pdfStructureContent, bool, error) { + var key pdfStructureContent + value := sequence.Properties["MCID"] + if value == nil { + return key, false, nil + } + if sequence.Reader == nil || sequence.Page == nil { + return key, false, fmt.Errorf("missing PDF marked content source") + } + value, err := sequence.Reader.Resolve(value) + if err != nil || value == nil { + return key, false, err + } + mcid, ok := value.(pdfgo.Integer) + if !ok || mcid < 0 { + return key, false, fmt.Errorf("invalid PDF marked content ID") + } + return pdfStructureContent{reader: sequence.Reader, page: sequence.Page.Reference, stream: sequence.Stream, mcid: int64(mcid)}, true, nil +} + +// contentProperties 保存区段标签及原始属性,重复调用须具有相同内容 +// 入参: values 类型化属性编码器 +// 返回: map[pdfStructureContent]StructureNode 属性索引, error 属性或取消错误 +func (b *pdfStructureBindings) contentProperties(values *pdfStructureValues) (map[pdfStructureContent]StructureNode, error) { + result := make(map[pdfStructureContent]StructureNode) + seen := make(map[*pdfgo.ContentSequence]bool) + for _, sequences := range [][]*pdfgo.ContentSequence{b.contents, b.order} { + for _, sequence := range sequences { + if err := values.ctx.Err(); err != nil { + return nil, err + } + if seen[sequence] || sequence.Reader != values.reader { + continue + } + seen[sequence] = true + key, ok, err := pdfStructureContentKey(sequence) + if err != nil { + return nil, err + } + if !ok { + continue + } + node := StructureNode{Name: "MarkedContent", Namespace: pdfStructureNamespace, Attributes: pdfStructureAttribute("Tag", string(sequence.Tag))} + properties := sequence.Properties + if _, ok := properties["MCID"].(pdfgo.Integer); ok { + properties = maps.Clone(properties) + delete(properties, "MCID") + } + if len(properties) != 0 { + property, err := values.encode(properties, 0) + if err != nil { + return nil, err + } + node.Children = append(node.Children, property) + } + if previous, ok := result[key]; ok { + equal, err := pdfStructurePropertiesEqual(values.ctx, previous, node) + if err != nil { + return nil, err + } + if !equal { + return nil, fmt.Errorf("conflicting PDF marked content properties") + } + continue + } + result[key] = node + } + } + return result, values.ctx.Err() +} + +// pdfStructurePropertiesEqual 比较类型化属性树,不生成临时XML +// 入参: ctx 取消上下文, left 原属性, right 待比较属性 +// 返回: bool 属性是否一致, error 取消错误 +func pdfStructurePropertiesEqual(ctx context.Context, left, right StructureNode) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if left.Name != right.Name || left.Namespace != right.Namespace || left.Text != right.Text || !slices.Equal(left.Attributes, right.Attributes) || len(left.Children) != len(right.Children) { + return false, nil + } + for i := range left.Children { + equal, err := pdfStructurePropertiesEqual(ctx, left.Children[i], right.Children[i]) + if err != nil || !equal { + return equal, err + } + } + return true, nil +} + +// contentReferences 按来源解释MCID并汇总重复调用的目标图元 +// 入参: ctx 取消上下文 +// 返回: pdfStructureReferences 内容引用索引, error 属性或取消错误 +func (b *pdfStructureBindings) contentReferences(ctx context.Context) (pdfStructureReferences, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + result := make(pdfStructureReferences) + sets := make(map[pdfStructureContent]*pdfStructureReferenceSet) + for _, sequence := range b.order { + if err := ctx.Err(); err != nil { + return nil, err + } + key, ok, err := pdfStructureContentKey(sequence) + if err != nil { + return nil, err + } + if !ok { + continue + } + set := sets[key] + if set == nil { + set = &pdfStructureReferenceSet{} + sets[key] = set + } + if err := set.append(ctx, b.sequences[sequence]); err != nil { + return nil, err + } + result[key] = set.references + } + return result, ctx.Err() +} + +// append 归并引用并去重,不持有输入切片 +// 入参: ctx 取消上下文, sources 来源引用 +// 返回: error 取消错误 +func (s *pdfStructureReferenceSet) append(ctx context.Context, sources []StructureReference) error { + if err := ctx.Err(); err != nil { + return err + } + for _, source := range sources { + if err := ctx.Err(); err != nil { + return err + } + index := -1 + if last := len(s.references) - 1; last >= 0 && s.references[last].PageID == source.PageID { + index = last + } else if page, ok := s.pages[source.PageID]; ok { + index = page + } + for _, id := range source.ObjectIDs { + if err := ctx.Err(); err != nil { + return err + } + key := [2]string{source.PageID, id} + if s.seen[key] { + continue + } + if s.seen == nil { + s.seen = make(map[[2]string]bool) + } + s.seen[key] = true + if index < 0 { + index = len(s.references) + if index == 1 { + s.pages = map[string]int{s.references[0].PageID: 0} + } + if s.pages != nil { + s.pages[source.PageID] = index + } + s.references = append(s.references, StructureReference{PageID: source.PageID}) + } + s.references[index].ObjectIDs = append(s.references[index].ObjectIDs, id) + } + } + return ctx.Err() +} + +// structureBindings 在提交前收集直接来源及含MCID的外层区段 +// 入参: ctx 取消上下文 +// 返回: []pdfStructureBinding 绑定位置, error 取消错误 +func (buffer *pdfObjectBuffer) structureBindings(ctx context.Context) ([]pdfStructureBinding, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if buffer == nil { + return nil, nil + } + var result []pdfStructureBinding + for index, run := range buffer.sequences { + if err := ctx.Err(); err != nil { + return nil, err + } + end := buffer.len() + if index+1 < len(buffer.sequences) { + end = buffer.sequences[index+1].start + } + var sources pdfOriginSet + collect := func(sequence *pdfgo.ContentSequence) error { + for current := sequence; current != nil; current = current.Parent { + if err := ctx.Err(); err != nil { + return err + } + if current == sequence || current.Properties["MCID"] != nil || current.Object != (pdfgo.Reference{}) { + sources.add(current) + } + } + return nil + } + if err := collect(run.sequence); err != nil { + return nil, err + } + for _, sequence := range run.related { + if err := collect(sequence); err != nil { + return nil, err + } + } + if !sources.started { + continue + } + result = append(result, pdfStructureBinding{sequence: sources.origin.sequence, start: run.start, end: end}) + for _, sequence := range sources.origin.related { + result = append(result, pdfStructureBinding{sequence: sequence, start: run.start, end: end}) + } + } + return result, ctx.Err() +} + +// append 在页面提交成功后登记已分配的图元标识 +// 入参: pageID 页面标识, objects 已分配标识的对象, bindings 已准备的绑定位置 +func (b *pdfStructureBindings) append(pageID string, objects []GraphicObject, bindings []pdfStructureBinding) { + for _, binding := range bindings { + last := b.reference(pageID, binding.sequence) + for _, object := range objects[binding.start:binding.end] { + last.ObjectIDs = append(last.ObjectIDs, editorObjectID(object)) + } + } +} + +// reference 返回已提交来源区段在页面中的引用 +// 入参: pageID 页面标识, sequence 来源区段 +// 返回: *StructureReference 页面引用 +func (b *pdfStructureBindings) reference(pageID string, sequence *pdfgo.ContentSequence) *StructureReference { + if b.sequences == nil { + b.sequences = make(map[*pdfgo.ContentSequence][]StructureReference) + } + refs := b.sequences[sequence] + if len(refs) == 0 { + b.order = append(b.order, sequence) + } + if len(refs) == 0 || refs[len(refs)-1].PageID != pageID { + refs = append(refs, StructureReference{PageID: pageID}) + } + b.sequences[sequence] = refs + return &refs[len(refs)-1] +} diff --git a/ofdgo_pdf_text.go b/ofdgo_pdf_text.go index a938054..2e206d3 100644 --- a/ofdgo_pdf_text.go +++ b/ofdgo_pdf_text.go @@ -35,16 +35,13 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error { return &pdfgo.UnsupportedError{Feature: "text blend mode " + string(mark.Style.BlendMode)} } if mark.Font.Subtype == pdfgo.Name("Type3") { - if err := p.flushPath(); err != nil { - return err - } visitor := p.visitor() for index := range mark.Glyphs { if err := p.reader.WalkType3Glyph(p.ctx, mark, index, visitor); err != nil { return err } } - return p.flushPath() + return p.ctx.Err() } if mark.Size == 0 || mark.HorizontalScale == 0 { return p.degenerateText(mark) @@ -59,9 +56,6 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error { } mark.Positions = positions } - if err := p.flushPath(); err != nil { - return err - } var err error mark.Style, err = p.maskStyle(mark.Style) if err != nil { @@ -377,14 +371,14 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error { if matrix == (pdfgo.Matrix{}) { matrix = pdfgo.Identity() } - if err := p.appendPath(pdfgo.PathMark{Path: path, Style: mark.Style, Fill: fill, Stroke: true, StrokeMatrix: matrix}); err != nil { + if err := p.appendPath(pdfgo.PathMark{Sequence: mark.Sequence, Path: path, Style: mark.Style, Fill: fill, Stroke: true, StrokeMatrix: matrix}); err != nil { return err } } object.Visible = new(bool) object.Fill, object.Stroke = new(bool), new(bool) } - p.appendObject(GraphicObject{Type: "TextObject", TextObject: object}) + p.appendOriginObject(GraphicObject{Type: "TextObject", TextObject: object}, p.textOrigin(mark)) p.report.TextObjects++ return nil } diff --git a/ofdgo_pdf_text_replacement.go b/ofdgo_pdf_text_replacement.go index d3fa51a..43f918b 100644 --- a/ofdgo_pdf_text_replacement.go +++ b/ofdgo_pdf_text_replacement.go @@ -78,15 +78,30 @@ func (r *pdfTextReplacements) block(text *pdfgo.ReplacementText) { } } -// markedContent 核对已接入的替换文本属性,不静默丢弃其他内容语义 +// markedContent 处理结构区段、可选内容及替换文本属性 // 入参: mark 内容标记 // 返回: error 未支持的语义错误 func (r *pdfTextReplacements) markedContent(mark pdfgo.MarkedContentMark) error { if mark.Operator == "EMC" { return nil } + if r.importer.structureBindings != nil { + if err := r.importer.prepareSourceStructure(r.importer.ctx, mark.Reader); err != nil { + return err + } + r.importer.structureBindings.observe(mark.Sequence) + if mark.Sequence != nil && r.importer.structureBindings.marked != nil { + key, ok, err := pdfStructureContentKey(mark.Sequence) + if err != nil { + return err + } + if ok && r.importer.structureBindings.marked[key] { + return nil + } + } + } if mark.Tag == "OC" { - kind, err := r.importer.reader.Resolve(mark.Properties["Type"]) + kind, err := mark.Reader.Resolve(mark.Properties["Type"]) if err != nil { return err } @@ -229,6 +244,23 @@ func (r *pdfTextReplacements) merge(nodes []pdfCompositeNode) ([]pdfCompositeNod end = max(end, i+1) if len(marks) == span.count { if mark, ok := pdfMergeReplacement(marks, span.text); ok { + var origins pdfOriginSet + for _, source := range marks { + if err := r.importer.ctx.Err(); err != nil { + return nil, err + } + origin := r.importer.textOrigin(*source.text) + origins.add(origin.sequence) + for _, sequence := range origin.related { + origins.add(sequence) + } + } + if len(origins.origin.related) != 0 { + if r.importer.textOrigins == nil { + r.importer.textOrigins = make(map[*pdfgo.ReplacementText]pdfObjectOrigin) + } + r.importer.textOrigins[span.text] = origins.origin + } if node.text != nil { node.text = mark } else { @@ -267,6 +299,16 @@ func (r *pdfTextReplacements) clear(nodes []pdfCompositeNode) { } } +// textOrigin 获取合并文字的完整来源,普通文字沿用原始区段 +// 入参: mark 文字图元 +// 返回: pdfObjectOrigin 来源区段 +func (p *pdfImporter) textOrigin(mark pdfgo.TextMark) pdfObjectOrigin { + if origin, ok := p.textOrigins[mark.Replacement]; ok { + return origin + } + return pdfObjectOrigin{sequence: mark.Sequence} +} + // pdfMergeReplacement 统一同一替换区段内的坐标原点,不重新塑形或调整字距 // 入参: nodes 连续文字图元, replacement 整段原文 // 返回: *pdfgo.TextMark 合并文字, bool 是否可等价合并 @@ -282,6 +324,7 @@ func pdfMergeReplacement(nodes []pdfCompositeNode, replacement *pdfgo.Replacemen return nil, false } count := 0 + sequence := first.Sequence for _, node := range nodes { mark := node.text if mark.Font != first.Font || mark.Size != first.Size || mark.HorizontalScale != first.HorizontalScale || mark.Mode != first.Mode || mark.Clip != nil || len(mark.Glyphs) != len(mark.Positions) || !reflect.DeepEqual(mark.Style, style) { @@ -291,12 +334,14 @@ func pdfMergeReplacement(nodes []pdfCompositeNode, replacement *pdfgo.Replacemen return nil, false } count += len(mark.Glyphs) + sequence = pdfCommonSequence(sequence, mark.Sequence) } if count == 0 { return nil, false } merged := *first merged.Replacement = replacement + merged.Sequence = sequence merged.Glyphs = make([]pdfgo.Glyph, 0, count) merged.Positions = make([]pdfgo.Point, 0, count) for _, node := range nodes { @@ -316,6 +361,36 @@ func pdfMergeReplacement(nodes []pdfCompositeNode, replacement *pdfgo.Replacemen return &merged, true } +// pdfCommonSequence 查找两个内容区段的最近共同外层,不将合并内容归入单个子区段 +// 入参: first 首个区段, second 后续区段 +// 返回: *pdfgo.ContentSequence 共同区段,没有共同区段时为空 +func pdfCommonSequence(first, second *pdfgo.ContentSequence) *pdfgo.ContentSequence { + if first == second { + return first + } + if first == nil || second == nil { + return nil + } + depth := func(sequence *pdfgo.ContentSequence) int { + count := 0 + for ; sequence != nil; sequence = sequence.Parent { + count++ + } + return count + } + a, b := depth(first), depth(second) + for ; a > b; a-- { + first = first.Parent + } + for ; b > a; b-- { + second = second.Parent + } + for first != second { + first, second = first.Parent, second.Parent + } + return first +} + // pdfApplyReplacement 将整段原文映射到全部字形,字形位移遵循GB/T 33190的文字定位规则 // 入参: object 文字对象, text 整段原文, positions 原始字形位置 func pdfApplyReplacement(object *TextObject, text string, positions []pdfgo.Point) { diff --git a/ofdgo_renderer_geom_canvas.go b/ofdgo_renderer_geom_canvas.go index 533d288..0cea166 100644 --- a/ofdgo_renderer_geom_canvas.go +++ b/ofdgo_renderer_geom_canvas.go @@ -177,7 +177,7 @@ func (CanvasBackend) Stroke(path GeometryPath, options StrokeOptions) (result Ge style.applyLineJoin(options.Join, options.MiterLimit, options.MiterLimitSet) style.lineCap = pathLineCap(options.Cap, style.lineCap) p = p.Dash(options.DashOffset, options.Dashes...) - p = p.Flatten(tolerance) + p = flattenCanvasPath(p, tolerance) p = p.Stroke(options.Width, style.lineCap, style.lineJoin, tolerance) return *geometryFromCanvasPath(p), nil } @@ -199,6 +199,27 @@ func (CanvasBackend) Clip(r *Renderer, clips *Clips, matrix Matrix, parent *Geom return geometryFromCanvasPath(r.intersectCachedClipPath(p, clip)), nil } +// flattenCanvasPath 按子路径展开曲线,避免每次替换都复制后续独立轮廓 +// 入参: path 原始路径, tolerance 展开误差 +// 返回: *canvas.Path 直线路径,不修改原始路径 +func flattenCanvasPath(path *canvas.Path, tolerance float64) *canvas.Path { + if path.IsFlat() { + return path + } + if data := path.Data(); len(data) > 0 && data[len(data)-1] == canvas.MoveToCmd { + return path.Flatten(tolerance) + } + parts := path.Split() + if len(parts) <= 1 { + return path.Flatten(tolerance) + } + data := make([]float64, 0, len(path.Data())) + for _, part := range parts { + data = append(data, part.Flatten(tolerance).Data()...) + } + return canvas.NewPathFromData(data) +} + // canvasNativeStroke 判断描边是否由Canvas提供 // 入参: geometry 几何后端 // 返回: bool 是否采用原生描边 diff --git a/ofdgo_renderer_output_canvas.go b/ofdgo_renderer_output_canvas.go index e09cc5d..37a08a4 100644 --- a/ofdgo_renderer_output_canvas.go +++ b/ofdgo_renderer_output_canvas.go @@ -147,7 +147,21 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i if docInfo, err := r.Reader.DocInfo(); err == nil { info = *docInfo } - p.SetInfo(info.Title, info.Subject, "", info.Author, ofdCreator) + renderer.information = make(pdfgo.Dictionary) + for _, field := range []struct { + key pdfgo.Name + value string + }{{"Title", info.Title}, {"Subject", info.Subject}, {"Author", info.Author}, {"Creator", ofdCreator}} { + if field.value == "" { + continue + } + encoded, err := pdfgo.EncodeTextString(field.value) + if err != nil { + buf.Truncate(start) + return err + } + renderer.information[field.key] = encoded + } for i, page := range pages { content, err := page.ReadContent(ctx, r.Reader) if err != nil { @@ -201,7 +215,7 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i buf.Truncate(start) return err } - data := replacePDFProducer(buf.Bytes()[start:]) + data := buf.Bytes()[start:] options := pdfgo.OptimizeOptions{Compression: r.Compression} if r.Compression.Mode != CompressionUnchanged && progress != nil { options.OnProgress = func(_ string, _, _ int) error { @@ -209,13 +223,6 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i } } if direct { - if !renderer.exactImages && !renderer.exactText && !navigation.nativeWrite { - if err := r.outputContext().Err(); err != nil { - buf.Truncate(start) - return err - } - return nil - } var result bytes.Buffer err := renderer.writePDF(r.outputContext(), data, &result, options) buf.Truncate(start) @@ -227,17 +234,3 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i } return renderer.writePDF(r.outputContext(), data, writer, options) } - -// replacePDFProducer 替换PDF的Producer属性 -// 入参: data PDF字节数据 -// 返回: []byte 替换后的PDF字节数据 -func replacePDFProducer(data []byte) []byte { - old := []byte("/Producer(tdewolff/canvas)") - idx := bytes.LastIndex(data, old) - if idx < 0 { - return data - } - dst := []byte("/Producer(" + ofdCreator + ")") - copy(data[idx:idx+len(old)], dst) - return data -} diff --git a/ofdgo_renderer_path_canvas.go b/ofdgo_renderer_path_canvas.go index afb6b70..52af481 100644 --- a/ofdgo_renderer_path_canvas.go +++ b/ofdgo_renderer_path_canvas.go @@ -312,6 +312,12 @@ func (r *Renderer) renderPath(ctx *canvas.Context, obj PathObject, pageH float64 ctx.SetDashes(0) } strokeClip := r.intersectCachedClipPath(clipPath, shadingClip) + if bounds, ok := rectangularPath(strokeClip); ok { + margin := style.lineWidth / 2 * math.Max(math.Sqrt2, style.miterLimit) + if finite(margin) && bounds.Contains(sp.Bounds().Expand(margin)) { + strokeClip = nil + } + } _, repeat := strokePaint.(*repeatAxialGradient) if strokeClip != nil || strokeView != canvas.Identity || repeat || style.strokePattern != nil || !canvasNativeStroke(r.backends.Geometry) || zeroDash { sp = sp.Copy() diff --git a/ofdgo_renderer_pdf_canvas.go b/ofdgo_renderer_pdf_canvas.go index 4af0083..93d1cd1 100644 --- a/ofdgo_renderer_pdf_canvas.go +++ b/ofdgo_renderer_pdf_canvas.go @@ -20,7 +20,9 @@ import ( "image" "image/color" "io" + "maps" "strconv" + "time" "github.com/tdewolff/canvas" cimage "github.com/tdewolff/canvas/image" @@ -32,6 +34,7 @@ import ( // pdfRenderer PDF渲染器 type pdfRenderer struct { *pdf.PDF + information pdfgo.Dictionary glyphPaths renderCache[*canvas.Path, *canvas.Path] fonts map[*canvas.Font]*canvas.Font cffFonts map[*canvas.Font][]*pdfFontSubset @@ -159,7 +162,14 @@ func (r *pdfRenderer) writePDF(ctx context.Context, data []byte, writer io.Write if r.imageError != nil { return r.imageError } + information := maps.Clone(r.information) + if information == nil { + information = make(pdfgo.Dictionary) + } + information["Producer"] = pdfgo.String(ofdCreator) + information["CreationDate"] = pdfgo.String(time.Now().UTC().Format("D:20060102150405Z")) options := codecpdf.WriteOptions{ + Information: information, Images: r.images, Text: r.text, ExactImages: r.exactImages, diff --git a/ofdgo_structure.go b/ofdgo_structure.go index f024ddd..06a3ed4 100644 --- a/ofdgo_structure.go +++ b/ofdgo_structure.go @@ -45,7 +45,7 @@ type Structure struct { // StructureNode 保存标引节点的名称、属性、直接文本和有序子节点,不改变原始XML // Text按文档顺序汇总直接文本并保留空白,不包含子节点文本 -// References仅解释已识别公文、富文本或发票标引中的ObjectRef;未知扩展保留结构,不推定其引用语义 +// References仅解释已识别标引中的ObjectRef;未知扩展保留结构,不推定其引用语义 type StructureNode struct { Name string `json:"name"` Namespace string `json:"namespace,omitempty"` @@ -132,11 +132,11 @@ func (r *Reader) readStructureContext(ctx context.Context, tag CustomTag) (Struc // 入参: ctx 取消上下文, data 标引XML, tag 标引入口 // 返回: *StructureNode 根节点, error 解析、限额或取消错误 func parseStructure(ctx context.Context, data []byte, tag CustomTag) (*StructureNode, error) { + data = bytes.TrimPrefix(data, []byte{0xef, 0xbb, 0xbf}) d := xml.NewDecoder(imageInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), context: ctx}) var root *StructureNode var stack []*StructureNode var text []*strings.Builder - count := 0 for { if err := ctx.Err(); err != nil { return nil, err @@ -150,15 +150,19 @@ func parseStructure(ctx context.Context, data []byte, tag CustomTag) (*Structure } switch token := token.(type) { case xml.StartElement: - count++ - if len(stack) >= 128 || count > 100000 { + if len(stack) >= 128 { return nil, fmt.Errorf("custom tag structure limit exceeded") } if len(stack) == len(text) { text = append(text, new(strings.Builder)) } node := StructureNode{Name: token.Name.Local, Namespace: token.Name.Space} + attributes := make(map[xml.Name]bool, len(token.Attr)) for _, attr := range token.Attr { + if attributes[attr.Name] { + return nil, fmt.Errorf("duplicate custom tag attribute") + } + attributes[attr.Name] = true if attr.Name.Space != "xmlns" && !(attr.Name.Space == "" && attr.Name.Local == "xmlns") { node.Attributes = append(node.Attributes, StructureAttribute{Name: attr.Name.Local, Namespace: attr.Name.Space, Value: attr.Value}) } @@ -301,7 +305,7 @@ func (r *Reader) structureReferenceTags(ctx context.Context, tags []CustomTag) ( return known, nil } -// structureOfficial 识别公文、富文本及发票标引的明确引用规则,不推断未知行业语义 +// structureOfficial 识别公文及库内标引的明确引用规则,不推断未知行业语义 // 入参: tag 标引入口, namespace 内容根节点命名空间 // 返回: bool 是否使用公文引用规则 func structureOfficial(tag CustomTag, namespace string) bool { @@ -311,6 +315,9 @@ func structureOfficial(tag CustomTag, namespace string) bool { if tag.TypeID == richTextTagType && tag.NameSpace == richTextNamespace && namespace == richTextNamespace { return true } + if tag.TypeID == pdfStructureTagType && tag.NameSpace == pdfStructureNamespace && namespace == pdfStructureNamespace { + return true + } if tag.TypeID == structureTypeOfficial { return true } diff --git a/ofdgo_structure_xml.go b/ofdgo_structure_xml.go index 314398c..73cc074 100644 --- a/ofdgo_structure_xml.go +++ b/ofdgo_structure_xml.go @@ -29,13 +29,21 @@ import ( // 入参: node 根节点 // 返回: []byte XML片段, error 名称、属性或编码错误 func encodeStructureNode(node StructureNode) ([]byte, error) { + return encodeStructureNodeContext(context.Background(), node) +} + +// encodeStructureNodeContext 编码标引子树并检查取消状态 +// 入参: ctx 取消上下文, node 根节点 +// 返回: []byte XML片段, error 名称、深度、编码或取消错误 +func encodeStructureNodeContext(ctx context.Context, node StructureNode) ([]byte, error) { var output bytes.Buffer encoder := xml.NewEncoder(&output) - count := 0 var write func(StructureNode, int) error write = func(node StructureNode, depth int) error { - count++ - if depth > 128 || count > 100000 { + if err := ctx.Err(); err != nil { + return err + } + if depth > 128 { return fmt.Errorf("custom tag structure limit exceeded") } if err := validateStructureNode(node); err != nil { diff --git a/ofdgo_writer.go b/ofdgo_writer.go index a7fceee..e42a6b7 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -400,12 +400,19 @@ func (e *Editor) writePartsWithPages(ctx context.Context, write func(string, []b {"DocID", e.Info.DocID}, {"Title", e.Info.Title}, {"Author", e.Info.Author}, {"Subject", e.Info.Subject}, {"Abstract", e.Info.Abstract}, {"CreationDate", e.Info.CreationDate}, {"ModDate", e.Info.ModDate}, - {"Creator", ofdCreator}, } { if field[1] != "" { x.text(field[0], field[1]) } } + if len(e.Info.Keywords) != 0 { + x.start("Keywords", nil) + for _, keyword := range e.Info.Keywords { + x.text("Keyword", keyword) + } + x.end("Keywords") + } + x.text("Creator", ofdCreator) if e.Info.CustomDatas != nil && len(e.Info.CustomDatas.CustomData) != 0 { x.start("CustomDatas", nil) for _, item := range e.Info.CustomDatas.CustomData {