diff --git a/internal/codec/pdf/geometry.go b/internal/codec/pdf/geometry.go index 42fc2da..43dba4c 100644 --- a/internal/codec/pdf/geometry.go +++ b/internal/codec/pdf/geometry.go @@ -17,6 +17,8 @@ package pdf import ( "context" "fmt" + "math" + "strconv" basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom" "github.com/xiaoqidun/pdfgo" @@ -99,3 +101,67 @@ func GlyphPath(outline basegeom.Path, matrix pdfgo.Matrix) (pdfgo.Path, error) { } return path, nil } + +// PathBounds 逐段计算变换后的控制点边界,不创建临时点集合 +// 入参: ctx 取消上下文, path PDF路径, matrix 坐标变换 +// 返回: basegeom.Box 控制点边界,空路径返回零值, error 取消错误 +func PathBounds(ctx context.Context, path pdfgo.Path, matrix pdfgo.Matrix) (basegeom.Box, error) { + if err := ctx.Err(); err != nil { + return basegeom.Box{}, err + } + minX, minY, maxX, maxY := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1) + found := false + for _, segment := range path.Segments { + if err := ctx.Err(); err != nil { + return basegeom.Box{}, err + } + for index, point := range segment.Points { + if index&255 == 0 { + if err := ctx.Err(); err != nil { + return basegeom.Box{}, err + } + } + point = matrix.Apply(point) + minX, minY = math.Min(minX, point.X), math.Min(minY, point.Y) + maxX, maxY = math.Max(maxX, point.X), math.Max(maxY, point.Y) + found = true + } + } + if err := ctx.Err(); err != nil { + return basegeom.Box{}, err + } + if !found { + return basegeom.Box{}, nil + } + return basegeom.Box{X: minX, Y: minY, W: math.Max(maxX-minX, 1e-6), H: math.Max(maxY-minY, 1e-6)}, nil +} + +// PathData 转换路径并在分段处理时检查取消 +// 入参: ctx 取消上下文, path PDF路径, matrix 坐标变换, origin 对象边界 +// 返回: string OFD路径数据, error 取消错误 +func PathData(ctx context.Context, path pdfgo.Path, matrix pdfgo.Matrix, origin basegeom.Box) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + data := make([]byte, 0, len(path.Segments)*48) + for index, segment := range path.Segments { + if err := ctx.Err(); err != nil { + return "", err + } + if index != 0 { + data = append(data, ' ') + } + data = append(data, segment.Operator...) + for _, point := range segment.Points { + point = matrix.Apply(point) + data = append(data, ' ') + data = strconv.AppendFloat(data, point.X-origin.X, 'g', -1, 64) + data = append(data, ' ') + data = strconv.AppendFloat(data, point.Y-origin.Y, 'g', -1, 64) + } + } + if err := ctx.Err(); err != nil { + return "", err + } + return string(data), nil +} diff --git a/internal/codec/pdf/metadata.go b/internal/codec/pdf/metadata.go new file mode 100644 index 0000000..a8d044e --- /dev/null +++ b/internal/codec/pdf/metadata.go @@ -0,0 +1,51 @@ +// 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 pdf + +import ( + "fmt" + "strings" + "time" + + "github.com/xiaoqidun/pdfgo" +) + +// MetadataDate 保留当地日历日期,PDF省略时区按GMT,XMP省略日或时区时不推算 +// 入参: text 来源日期, xmp 是否为XMP日期 +// 返回: time.Time 解析值, bool 是否精确到日, bool 是否可比较绝对时间, error 日期错误 +func MetadataDate(text string, xmp bool) (time.Time, bool, bool, error) { + if text == "" { + return time.Time{}, false, false, nil + } + if !xmp { + date, err := pdfgo.ParseDate(text) + return date, err == nil && date.Year() > 0, err == nil, err + } + if !strings.Contains(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, text); err == nil { + zoned := strings.Contains(layout, "Z07:00") + if zoned && !strings.HasSuffix(text, "Z") { + zone := text[len(text)-6:] + if zone[1:3] > "23" || zone[4:] > "59" { + return time.Time{}, false, false, fmt.Errorf("invalid XMP time zone") + } + } + return date, len(text) >= 10 && date.Year() > 0, zoned, nil + } + } + } + return time.Time{}, false, false, fmt.Errorf("invalid XMP date") +} diff --git a/internal/codec/pdf/text.go b/internal/codec/pdf/text.go new file mode 100644 index 0000000..8d336cc --- /dev/null +++ b/internal/codec/pdf/text.go @@ -0,0 +1,102 @@ +// 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 pdf + +import ( + "math" + "reflect" + + "github.com/xiaoqidun/pdfgo" +) + +// MergeReplacement 统一同一替换区段内的坐标原点,不重新塑形或调整字距 +// 入参: marks 连续文字图元, replacement 整段原文 +// 返回: *pdfgo.TextMark 合并文字, bool 是否可等价合并 +func MergeReplacement(marks []*pdfgo.TextMark, replacement *pdfgo.ReplacementText) (*pdfgo.TextMark, bool) { + first := marks[0] + style := first.Style + paint := style.Fill + if first.Font == nil || len(first.Font.Program) == 0 || first.Mode != 0 || first.Size <= 0 || first.HorizontalScale <= 0 || first.Clip != nil || style.SoftMask != nil || style.Transfer != nil || style.AlphaIsShape || style.FillOverprint || paint.Alpha != 1 || !NormalBlend(style.BlendMode) || paint.Tiling != nil || paint.Axial != nil || paint.Radial != nil || paint.Mesh != nil || paint.Function != nil { + return nil, false + } + inverse, ok := first.Matrix.Inverse() + if !ok { + return nil, false + } + count := 0 + sequence := first.Sequence + for _, mark := range marks { + 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) { + return nil, false + } + if mark.Matrix[0] != first.Matrix[0] || mark.Matrix[1] != first.Matrix[1] || mark.Matrix[2] != first.Matrix[2] || mark.Matrix[3] != first.Matrix[3] { + return nil, false + } + count += len(mark.Glyphs) + sequence = CommonSequence(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 _, mark := range marks { + dx, dy := mark.Matrix[4]-first.Matrix[4], mark.Matrix[5]-first.Matrix[5] + offset := pdfgo.Point{X: inverse[0]*dx + inverse[2]*dy, Y: inverse[1]*dx + inverse[3]*dy} + for _, point := range mark.Positions { + point.X += offset.X + point.Y += offset.Y + if math.IsNaN(point.X) || math.IsNaN(point.Y) || math.IsInf(point.X, 0) || math.IsInf(point.Y, 0) { + return nil, false + } + merged.Positions = append(merged.Positions, point) + } + merged.Glyphs = append(merged.Glyphs, mark.Glyphs...) + } + return &merged, true +} + +// CommonSequence 查找两个内容区段的最近共同外层,不将合并内容归入单个子区段 +// 入参: first 首个区段, second 后续区段 +// 返回: *pdfgo.ContentSequence 共同区段,没有共同区段时为空 +func CommonSequence(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 +} diff --git a/ofdgo_pdf_action.go b/ofdgo_pdf_action.go index 4284a97..057d447 100644 --- a/ofdgo_pdf_action.go +++ b/ofdgo_pdf_action.go @@ -15,13 +15,23 @@ package ofdgo import ( + "bytes" "context" + "encoding/xml" "errors" "fmt" + "math" + "strconv" + codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf" "github.com/xiaoqidun/pdfgo" ) +// pdfDestinationFontBackend 禁止内容度量忽略实际绘制所需的缺失字体 +type pdfDestinationFontBackend struct { + FontBackend +} + // documentOpenActions 转换文档初始目标与打开动作,不执行脚本或打开资源 // 入参: ctx 取消上下文 // 返回: error 动作、编码或取消错误 @@ -207,3 +217,197 @@ func (p *pdfImporter) annotationTriggerActions(ctx context.Context, annotation p } return converted, ctx.Err() } + +// ResolveFont 沿用字体匹配,只在实际绘制缺少字体数据时中止度量 +// 入参: renderer 度量渲染器, id 字体标识, exact 是否禁止无关回退 +// 返回: ResolvedFont 字体资源, error 解析或缺失错误 +func (b pdfDestinationFontBackend) ResolveFont(renderer *Renderer, id string, exact bool) (ResolvedFont, error) { + font, err := b.FontBackend.ResolveFont(renderer, id, exact) + if err == nil && len(font.Data) == 0 { + err = fmt.Errorf("PDF destination font %q unavailable", id) + } + return font, err +} + +// destinationBounds 度量目标页内容并缓存页面内范围,不包含注解或像素取整 +// 入参: page PDF目标页 +// 返回: Box OFD内容范围, error 度量错误 +func (p *pdfImporter) destinationBounds(page *pdfgo.Page) (Box, error) { + if err := p.ctx.Err(); err != nil { + return Box{}, err + } + if box, ok := p.destinationBoxes[page.Reference]; ok { + return box, nil + } + if index, ok := p.pageIndexes[page.Reference]; ok { + previous := p.page + p.page = index + defer func() { p.page = previous }() + } + matrix, width, height := codecpdf.PageMatrix(page) + editor := NewEditor() + editor.SetRenderBackends(p.editor.Backends()) + editor.SetFontDirs(p.editor.fontDirs...) + editor.SetFontFS(p.editor.fontFS...) + editor.fontSourcesCache = p.renderer.fontSourcesCache + editor.validatedPaths = p.editor.pathValidationCache() + if _, err := editor.AddPage(width, height); err != nil { + return Box{}, err + } + local := pdfImporter{ + ctx: p.ctx, reader: p.reader, editor: editor, renderer: p.renderer, + matrix: matrix, pageBox: page.CropBox, pageWidth: width, pageHeight: height, + rasterDPI: p.rasterDPI, fontIDs: make(map[*pdfgo.Font]string), + resolveFile: p.resolveFile, resolveReference: p.resolveReference, + references: &codecpdf.ReferenceCache{}, + halftones: p.halftones, halftoneWarnings: make(map[*pdfgo.Halftone]bool), + } + defer local.references.Close() + if err := local.compositePage(nil, func(visitor pdfgo.Visitor) error { + return p.reader.WalkPage(p.ctx, page, visitor) + }); err != nil { + return Box{}, fmt.Errorf("PDF destination content: %w", err) + } + if err := local.commitObjects(); err != nil { + return Box{}, err + } + reader, err := editor.resourceReader() + if err != nil { + return Box{}, err + } + defer reader.Close() + renderer := p.renderer.childRenderer(reader) + renderer.TransparentBackground = true + renderer.RenderAnnotations = false + if renderer.backends.Fonts != nil { + renderer.backends.Fonts = pdfDestinationFontBackend{renderer.backends.Fonts} + } + scene, err := renderer.CompilePageContext(p.ctx, &editor.pages[0]) + if err != nil { + return Box{}, err + } + if scene == nil { + return Box{}, fmt.Errorf("PDF destination content scene unavailable") + } + geometry, err := renderer.Geometry() + if err != nil { + return Box{}, err + } + box, err := scene.BoundsContext(p.ctx, geometry) + if err != nil { + return Box{}, err + } + left, top := math.Max(0, box.X), math.Max(0, box.Y) + right, bottom := math.Min(width, box.X+box.W), math.Min(height, box.Y+box.H) + box = Box{X: left, Y: top, W: right - left, H: bottom - top} + if !finite(left) || !finite(top) || !finite(right) || !finite(bottom) || box.W <= 0 || box.H <= 0 { + return Box{}, fmt.Errorf("PDF destination content bounds empty or invalid") + } + if err := p.ctx.Err(); err != nil { + return Box{}, err + } + if p.destinationBoxes == nil { + p.destinationBoxes = make(map[pdfgo.Reference]Box) + } + p.destinationBoxes[page.Reference] = box + return box, nil +} + +// outlines 按先序批量转换目录,保留层级、展开状态和动作顺序 +// 入参: ctx 取消上下文 +// 返回: error 严格模式转换错误或取消错误 +func (p *pdfImporter) outlines(ctx context.Context) error { + count := 0 + data, err := encodeOFDXMLContext(ctx, func(x *ofdXML) { + x.root("Outlines", nil) + depth := 0 + err := p.reader.WalkOutlines(ctx, func(level int, item pdfgo.OutlineItem) error { + actions, err := p.outlineActions(ctx, item) + if err != nil { + if p.warning == nil || ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errPDFMediaRead) { + return err + } + p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outline %q actions not transferred to OFD: %v", item.Title, err)}) + actions = nil + } + for depth > level { + x.end("OutlineElem") + depth-- + } + attrs := ofdAttrs{{Name: xml.Name{Local: "Title"}, Value: item.Title}} + attrs.add("Expanded", strconv.FormatBool(item.Expanded)) + x.start("OutlineElem", attrs) + x.actions(actions) + depth++ + count++ + return x.err + }) + if err != nil { + x.err = err + return + } + for depth > 0 { + x.end("OutlineElem") + depth-- + } + x.end("Outlines") + }) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + if p.warning == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errPDFMediaRead) { + return err + } + p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outlines not transferred to OFD: %v", err)}) + return nil + } + if count > 0 { + p.editor.outlines = bytes.TrimPrefix(data, []byte(xml.Header)) + } + return nil +} + +// outlineActions 转换目录目标及动作链,不为无目标节点补充跳转 +// 入参: ctx 取消上下文, item PDF目录节点 +// 返回: []Action OFD动作序列, error 动作或取消错误 +func (p *pdfImporter) outlineActions(ctx context.Context, item pdfgo.OutlineItem) ([]Action, error) { + object, err := p.reader.Resolve(item.Action) + if err != nil { + return nil, err + } + dest, err := p.reader.Resolve(item.Destination) + if err != nil { + return nil, err + } + if dest != nil { + if object != nil { + return nil, fmt.Errorf("PDF outline has both destination and action") + } + object = pdfgo.Dictionary{"S": pdfgo.Name("GoTo"), "D": dest} + } + currentPage := -1 + var actions []Action + err = p.reader.WalkActions(ctx, object, func(info pdfgo.ActionInfo) error { + converted, err := p.linkActions(pdfgo.Annotation{Dictionary: pdfgo.Dictionary{"A": info.Dictionary}}, p.warning == nil, ¤tPage) + if err != nil { + if errors.Is(err, errPDFMediaRead) { + return err + } + var unsupported *pdfgo.UnsupportedError + if p.warning != nil && (errors.As(err, &unsupported) || errors.Is(err, pdfgo.ErrInvalidDestination) || errors.Is(err, pdfgo.ErrInvalidAction)) { + p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outline action not transferred to OFD: %v", err)}) + currentPage = -1 + return nil + } + return err + } + if converted != nil { + actions = append(actions, converted...) + } else { + currentPage = -1 + } + return nil + }) + return actions, err +} diff --git a/ofdgo_pdf_clip.go b/ofdgo_pdf_clip.go deleted file mode 100644 index 2f36894..0000000 --- a/ofdgo_pdf_clip.go +++ /dev/null @@ -1,258 +0,0 @@ -// 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 ( - "reflect" - - codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf" - "github.com/xiaoqidun/pdfgo" -) - -// pdfClipPath 保存导入器只读的裁剪几何,不缓存对象位移或文字裁剪 -type pdfClipPath struct { - box Box - data string - path *[1]PathObject -} - -// clipPath 复用页面内重复路径编码,按估算字节预算淘汰且不缓存错误 -// 入参: path 裁剪路径 -// 返回: pdfClipPath 变换后的边界及编码, error 取消错误 -func (p *pdfImporter) clipPath(path pdfgo.Path) (pdfClipPath, error) { - key, err := codecpdf.ClipPathKey(p.ctx, path, p.matrix) - if err != nil { - return pdfClipPath{}, err - } - if p.clipPaths == nil { - p.clipPaths = &renderCache[[32]byte, pdfClipPath]{limit: 8 << 20} - } - if cached, ok := p.clipPaths.get(key); ok { - return cached, nil - } - box, err := p.pathBoundsContext(p.ctx, path) - if err != nil { - return pdfClipPath{}, err - } - data, err := p.pathDataContext(p.ctx, path, box) - if err != nil { - return pdfClipPath{}, err - } - rule := "NonZero" - if path.EvenOdd { - rule = "Even-Odd" - } - boundary := pdfBoundary(box) - fill, stroke := true, false - result := pdfClipPath{box: box, data: data, path: &[1]PathObject{{Boundary: boundary, AbbreviatedData: data, Rule: rule, Fill: &fill, Stroke: &stroke}}} - p.clipPaths.put(key, result, len(data)+len(boundary)+512) - return result, nil -} - -// 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 - } - clips := pdfPageClip(objects[start]) - end := start + 1 - if clips != nil { - 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 - } - end++ - } - } - if end-start < 2 { - if output != nil { - mapped.recordOrigin(len(output), p.objects.origin(start)) - output = append(output, objects[start]) - } - start = end - continue - } - 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 { - *editorObjectClips(&members[index]) = nil - } - id, err := p.objectValidation().addOwnedVector(p.ctx, members, p.pageWidth, p.pageHeight) - 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, - }}) - start = end - } - if output == nil { - return objects, nil - } - if p.objects != nil { - mapped.parts = [][]GraphicObject{output} - mapped.size = len(output) - *p.objects = mapped - } - return output, nil -} - -// pdfPageClip 将导入裁剪区还原到页面坐标,仅提取包含大量重复路径数据的裁剪 -// 入参: object 导入对象 -// 返回: *Clips 页面坐标裁剪,不满足提取条件时为空 -func pdfPageClip(object GraphicObject) *Clips { - if object.Type != "TextObject" && object.Type != "PathObject" && object.Type != "ImageObject" { - return nil - } - clips := *editorObjectClips(&object) - if clips == nil || clips.usesObjectMatrix() || len(clips.Clip) == 0 { - return nil - } - boundary, _ := editorGeometry(object) - box, err := ParseBox(boundary) - if err != nil { - return nil - } - translation := "" - if box.X != 0 || box.Y != 0 { - translation = pdfNumbers(1, 0, 0, 1, -box.X, -box.Y) - } - bytes := 0 - result := &Clips{Clip: make([]Clip, len(clips.Clip))} - for index, clip := range clips.Clip { - if len(clip.Area) != 1 || clip.Area[0].CTM != translation { - return nil - } - area := clip.Area[0] - area.CTM = "" - result.Clip[index].Area = []ClipArea{area} - for _, path := range area.Path { - bytes += len(path.AbbreviatedData) - } - } - if bytes < 2048 { - return nil - } - return result -} - -// pdfMatchesPageClip 比较页面坐标裁剪,不将相同外接矩形视为相同路径 -// 入参: object 导入对象, pageClip 已还原的裁剪区 -// 返回: bool 是否可共享裁剪区 -func pdfMatchesPageClip(object GraphicObject, pageClip *Clips) bool { - if object.Type != "TextObject" && object.Type != "PathObject" && object.Type != "ImageObject" { - return false - } - clips := *editorObjectClips(&object) - if clips == nil || clips.usesObjectMatrix() || len(clips.Clip) != len(pageClip.Clip) { - return false - } - boundary, _ := editorGeometry(object) - box, err := ParseBox(boundary) - if err != nil { - return false - } - translation := "" - if box.X != 0 || box.Y != 0 { - translation = pdfNumbers(1, 0, 0, 1, -box.X, -box.Y) - } - for index, clip := range clips.Clip { - if len(clip.Area) != 1 || clip.Area[0].CTM != translation { - return false - } - area, expected := clip.Area[0], pageClip.Clip[index].Area[0] - if area.DrawParam != expected.DrawParam || !reflect.DeepEqual(area.Path, expected.Path) || !reflect.DeepEqual(area.Text, expected.Text) { - return false - } - } - return true -} - -// clips 省略覆盖对象边界的矩形裁剪,复用其余几何并保留位移及路径交集 -// 入参: paths 裁剪路径, origin 对象边界 -// 返回: *Clips OFD裁剪区域,无裁剪时为空, error 未解析的裁剪字形 -func (p *pdfImporter) clips(paths []pdfgo.Path, origin Box) (*Clips, error) { - if len(paths) == 0 { - return nil, nil - } - var clips *Clips - var areas []ClipArea - translation := "" - for _, path := range paths { - if err := p.ctx.Err(); err != nil { - return nil, err - } - if codecpdf.ClipContainsBoundary(path, p.matrix, origin) { - continue - } - if clips == nil { - clips = &Clips{Clip: make([]Clip, 0, len(paths))} - areas = make([]ClipArea, len(paths)) - if origin.X != 0 || origin.Y != 0 { - translation = pdfNumbers(1, 0, 0, 1, -origin.X, -origin.Y) - } - } - index := len(clips.Clip) - area := &areas[index] - area.CTM = translation - if len(path.Segments) != 0 { - encoded, err := p.clipPath(path) - if err != nil { - return nil, err - } - area.Path = encoded.path[:1:1] - } - for _, mark := range path.Text { - objects, ok := p.clipTexts[mark] - if !ok { - 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 { - return nil, err - } - p.objects.truncate(count) - p.report.TextObjects = textCount - objects = p.clipTexts[mark] - } - if len(path.Text) == 1 { - area.Text = objects[:len(objects):len(objects)] - } else { - area.Text = append(area.Text, objects...) - } - } - if len(area.Path)+len(area.Text) == 0 { - fill, stroke := true, false - area.Path = []PathObject{{Boundary: "0 0 1 1", AbbreviatedData: "M 0 0 C", Fill: &fill, Stroke: &stroke}} - } - clips.Clip = append(clips.Clip, Clip{Area: areas[index : index+1 : index+1]}) - } - return clips, nil -} diff --git a/ofdgo_pdf_font.go b/ofdgo_pdf_font.go deleted file mode 100644 index e35813a..0000000 --- a/ofdgo_pdf_font.go +++ /dev/null @@ -1,210 +0,0 @@ -// 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" - "crypto/sha256" - "fmt" - "unicode/utf8" - - basefont "github.com/xiaoqidun/ofdgo/internal/base/font" - codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf" - "github.com/xiaoqidun/pdfgo" -) - -// pdfImportedFont 保存单次导入共用的字体资源、度量及源字形映射,不保存文档标识 -type pdfImportedFont struct { - resource *FontResource - checksum [32]byte - metrics FontMetrics - type1Glyphs map[string]uint16 - repairLimit uint16 -} - -// shapePDFText 使用导入字体整形注释文字,将毫米坐标约定转换为PDF文字空间方向 -// 入参: ctx 取消上下文, source 源字体, text 单行原文, rightToLeft 段落方向 -// 返回: []pdfgo.ShapedGlyph 千分之一字号的定位字形, error 字体、整形或取消错误 -func (p *pdfImporter) shapePDFText(ctx context.Context, source *pdfgo.Font, text string, rightToLeft bool) ([]pdfgo.ShapedGlyph, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - if len(source.Program) == 0 { - return nil, &pdfgo.UnsupportedError{Feature: "annotation shaping without embedded font"} - } - font, err := p.importedFont(source) - if err != nil { - return nil, err - } - shaper, ok := font.metrics.(FontShaperOptions) - if !ok { - return nil, &pdfgo.UnsupportedError{Feature: "annotation text shaping backend"} - } - options := TextShapeOptions{Bidi: true} - if rightToLeft { - options.Direction = "rtl" - } - glyphs, err := shaper.ShapeTextWithOptions(text, 1000, options) - if err != nil { - return nil, err - } - result := make([]pdfgo.ShapedGlyph, len(glyphs)) - units := float64(font.metrics.UnitsPerEm()) - if units <= 0 { - return nil, fmt.Errorf("invalid annotation font units") - } - for i, glyph := range glyphs { - if err := ctx.Err(); err != nil { - return nil, err - } - result[i] = pdfgo.ShapedGlyph{ID: glyph.Glyph, Cluster: glyph.Cluster, VisualOrder: glyph.VisualOrder, X: glyph.X, Y: -glyph.Y, Advance: glyph.Advance, Width: float64(font.metrics.GlyphAdvance(glyph.Glyph)) * 1000 / units} - } - return result, ctx.Err() -} - -// embeddedTextFont 匹配同名内嵌字体,仅复用唯一且覆盖全部字符的字形程序 -// 入参: source 外部字体, glyphs 当前文字字形 -// 返回: *pdfImportedFont 可复用字体,无可靠匹配时为空 -func (p *pdfImporter) embeddedTextFont(source *pdfgo.Font, glyphs []pdfgo.Glyph) *pdfImportedFont { - name := codecpdf.EmbeddedFontName(source.Name) - if name == "" || len(glyphs) == 0 { - return nil - } - var matched *pdfImportedFont - for _, imported := range p.fonts { - if codecpdf.EmbeddedFontName(imported.resource.Font.FontName) != name { - continue - } - if matched != nil && matched.checksum != imported.checksum { - return nil - } - matched = imported - } - if matched == nil { - return nil - } - for _, glyph := range glyphs { - if glyph.Name == ".notdef" || utf8.RuneCountInString(glyph.Text) != 1 { - return nil - } - char, _ := utf8.DecodeRuneInString(glyph.Text) - id := matched.metrics.GlyphIndex(char) - if id == 0 || id >= matched.metrics.NumGlyphs() || matched.repairLimit != 0 && id >= matched.repairLimit { - return nil - } - } - return matched -} - -// importedFont 按原PDF字体复用包装和解析结果,失败结果不进入缓存 -// 入参: source 源字体 -// 返回: *pdfImportedFont 只读导入字体, error 字体程序或后端错误 -func (p *pdfImporter) importedFont(source *pdfgo.Font) (*pdfImportedFont, error) { - if cached := p.fonts[source]; cached != nil { - return cached, nil - } - var type1 *basefont.Type1Program - result := &pdfImportedFont{} - if source.ProgramType == "FontFile" { - parsed, err := basefont.ParseType1Program(source.Program) - if err != nil { - return nil, fmt.Errorf("PDF font %s program: %w", source.Name, err) - } - type1 = &parsed - result.type1Glyphs = parsed.GlyphIDs() - } - program, repair, err := codecpdf.FontProgram(source, type1) - if err != nil { - return nil, fmt.Errorf("PDF font %s program: %w", source.Name, err) - } - if repair.Locations && p.warning == nil { - return nil, fmt.Errorf("PDF font %s program: %w", source.Name, basefont.ErrTTInvalidLocations) - } - if p.editor.backends.FontResources == nil { - return nil, fmt.Errorf("PDF font %s resource: font resources: %w", source.Name, ErrBackendUnavailable) - } - result.resource, err = p.editor.backends.FontResources.OpenFontResource(FontFile{Name: source.Name + ".ttf", Data: program}, 0) - if err != nil { - return nil, fmt.Errorf("PDF font %s resource: %w", source.Name, err) - } - if p.editor.backends.Fonts == nil { - return nil, fmt.Errorf("PDF font backend unavailable") - } - result.metrics, err = p.editor.backends.Fonts.OpenFont(result.resource.Data) - if err != nil { - return nil, fmt.Errorf("PDF font %s metrics: %w", source.Name, err) - } - if _, ok := result.metrics.(FontOutlines); !ok { - return nil, fmt.Errorf("PDF font backend does not provide glyph outlines") - } - result.checksum, result.repairLimit = sha256.Sum256(result.resource.Data), repair.MetricsLimit - if repair.Locations { - p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF font %s has invalid glyph locations; index rebuilt with original glyph IDs", source.Name)}) - } - if p.fonts == nil { - p.fonts = make(map[*pdfgo.Font]*pdfImportedFont) - } - p.fonts[source] = result - return result, nil -} - -// symbolFont 加载标准符号字体,保留原字形编号和源字形名称 -// 入参: source 未嵌入的PDF字体 -// 返回: *pdfImportedFont 可用的符号字体,无匹配时为空, error 字体或取消错误 -func (p *pdfImporter) symbolFont(source *pdfgo.Font) (*pdfImportedFont, error) { - name := codecpdf.EmbeddedFontName(source.Name) - if source.Subtype != "Type1" || name != "Symbol" && name != "ZapfDingbats" { - return nil, nil - } - if imported, ok := p.symbolFonts[source]; ok { - return imported, nil - } - if p.symbolFonts == nil { - p.symbolFonts = make(map[*pdfgo.Font]*pdfImportedFont) - } - if err := p.ctx.Err(); err != nil { - return nil, err - } - definition := &Font{ID: name, FontName: name, FamilyName: name} - reader := &Reader{doc: &Document{}, fontResourcesRead: true, fontCache: map[string]*Font{name: definition}} - resolved, err := p.editor.newRenderer(reader).ResolveFont(name, true) - if err != nil || len(resolved.Data) == 0 || p.editor.backends.FontResources == nil { - p.symbolFonts[source] = nil - return nil, nil - } - program, names, err := basefont.SymbolProgram(p.ctx, resolved.Data, name) - if err != nil { - return nil, err - } - if len(names) == 0 { - p.symbolFonts[source] = nil - return nil, nil - } - resource, err := p.editor.backends.FontResources.OpenFontResource(FontFile{Name: name + ".ttf", Data: program}, 0) - if err != nil { - return nil, err - } - metrics, err := p.editor.backends.Fonts.OpenFont(resource.Data) - if err != nil { - return nil, err - } - if _, ok := metrics.(FontOutlines); !ok { - p.symbolFonts[source] = nil - return nil, nil - } - result := &pdfImportedFont{resource: resource, checksum: sha256.Sum256(resource.Data), metrics: metrics, type1Glyphs: names} - p.symbolFonts[source] = result - return result, nil -} diff --git a/ofdgo_pdf_geometry.go b/ofdgo_pdf_geometry.go index f1c8474..9f56be8 100644 --- a/ofdgo_pdf_geometry.go +++ b/ofdgo_pdf_geometry.go @@ -21,6 +21,7 @@ import ( "fmt" "image/color" "math" + "reflect" "strconv" basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom" @@ -34,6 +35,13 @@ type pdfPointBounds struct { found bool } +// pdfClipPath 保存导入器只读的裁剪几何,不缓存对象位移或文字裁剪 +type pdfClipPath struct { + box Box + data string + path *[1]PathObject +} + // pdfGeometryPath 转换PDF路径并衔接OFD几何类型 // 入参: ctx 取消上下文, source PDF路径, matrix 坐标变换 // 返回: GeometryPath 独立几何路径, error 指令、坐标或取消错误 @@ -286,70 +294,6 @@ func (b *pdfPointBounds) box() Box { return Box{X: b.minX, Y: b.minY, W: math.Max(b.maxX-b.minX, 1e-6), H: math.Max(b.maxY-b.minY, 1e-6)} } -// pathBoundsContext 逐段计算变换后的控制点边界,不创建临时点集合 -// 入参: ctx 取消上下文, path PDF路径 -// 返回: Box 控制点边界,空路径返回零值, error 取消错误 -func (p *pdfImporter) pathBoundsContext(ctx context.Context, path pdfgo.Path) (Box, error) { - if err := ctx.Err(); err != nil { - return Box{}, err - } - minX, minY, maxX, maxY := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1) - found := false - for _, segment := range path.Segments { - if err := ctx.Err(); err != nil { - return Box{}, err - } - for index, point := range segment.Points { - if index&255 == 0 { - if err := ctx.Err(); err != nil { - return Box{}, err - } - } - point = p.matrix.Apply(point) - minX, minY = math.Min(minX, point.X), math.Min(minY, point.Y) - maxX, maxY = math.Max(maxX, point.X), math.Max(maxY, point.Y) - found = true - } - } - if err := ctx.Err(); err != nil { - return Box{}, err - } - if !found { - return Box{}, nil - } - return Box{X: minX, Y: minY, W: math.Max(maxX-minX, 1e-6), H: math.Max(maxY-minY, 1e-6)}, nil -} - -// pathDataContext 转换路径并在分段处理时检查取消 -// 入参: ctx 取消上下文, path PDF路径, origin 对象边界 -// 返回: string OFD路径数据, error 取消错误 -func (p *pdfImporter) pathDataContext(ctx context.Context, path pdfgo.Path, origin Box) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - data := make([]byte, 0, len(path.Segments)*48) - for index, segment := range path.Segments { - if err := ctx.Err(); err != nil { - return "", err - } - if index != 0 { - data = append(data, ' ') - } - data = append(data, segment.Operator...) - for _, point := range segment.Points { - point = p.matrix.Apply(point) - data = append(data, ' ') - data = strconv.AppendFloat(data, point.X-origin.X, 'g', -1, 64) - data = append(data, ' ') - data = strconv.AppendFloat(data, point.Y-origin.Y, 'g', -1, 64) - } - } - if err := ctx.Err(); err != nil { - return "", err - } - return string(data), nil -} - // path 保留每次路径绘制操作,重叠描边不合并为单次绘制 // 入参: mark PDF路径绘制信息 // 返回: error 错误信息 @@ -383,7 +327,7 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error { } return p.gradientPath(mark) } - box, err := p.pathBoundsContext(p.ctx, mark.Path) + box, err := codecpdf.PathBounds(p.ctx, mark.Path, p.matrix) if err != nil { return err } @@ -447,7 +391,7 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error { if err != nil { return err } - data, err := drawing.pathDataContext(p.ctx, mark.Path, origin) + data, err := codecpdf.PathData(p.ctx, mark.Path, drawing.matrix, origin) if err != nil { return err } @@ -465,3 +409,232 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error { p.report.PathObjects++ return nil } + +// clipPath 复用页面内重复路径编码,按估算字节预算淘汰且不缓存错误 +// 入参: path 裁剪路径 +// 返回: pdfClipPath 变换后的边界及编码, error 取消错误 +func (p *pdfImporter) clipPath(path pdfgo.Path) (pdfClipPath, error) { + key, err := codecpdf.ClipPathKey(p.ctx, path, p.matrix) + if err != nil { + return pdfClipPath{}, err + } + if p.clipPaths == nil { + p.clipPaths = &renderCache[[32]byte, pdfClipPath]{limit: 8 << 20} + } + if cached, ok := p.clipPaths.get(key); ok { + return cached, nil + } + box, err := codecpdf.PathBounds(p.ctx, path, p.matrix) + if err != nil { + return pdfClipPath{}, err + } + data, err := codecpdf.PathData(p.ctx, path, p.matrix, box) + if err != nil { + return pdfClipPath{}, err + } + rule := "NonZero" + if path.EvenOdd { + rule = "Even-Odd" + } + boundary := pdfBoundary(box) + fill, stroke := true, false + result := pdfClipPath{box: box, data: data, path: &[1]PathObject{{Boundary: boundary, AbbreviatedData: data, Rule: rule, Fill: &fill, Stroke: &stroke}}} + p.clipPaths.put(key, result, len(data)+len(boundary)+512) + return result, nil +} + +// 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 + } + clips := pdfPageClip(objects[start]) + end := start + 1 + if clips != nil { + 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 + } + end++ + } + } + if end-start < 2 { + if output != nil { + mapped.recordOrigin(len(output), p.objects.origin(start)) + output = append(output, objects[start]) + } + start = end + continue + } + 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 { + *editorObjectClips(&members[index]) = nil + } + id, err := p.objectValidation().addOwnedVector(p.ctx, members, p.pageWidth, p.pageHeight) + 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, + }}) + start = end + } + if output == nil { + return objects, nil + } + if p.objects != nil { + mapped.parts = [][]GraphicObject{output} + mapped.size = len(output) + *p.objects = mapped + } + return output, nil +} + +// pdfPageClip 将导入裁剪区还原到页面坐标,仅提取包含大量重复路径数据的裁剪 +// 入参: object 导入对象 +// 返回: *Clips 页面坐标裁剪,不满足提取条件时为空 +func pdfPageClip(object GraphicObject) *Clips { + if object.Type != "TextObject" && object.Type != "PathObject" && object.Type != "ImageObject" { + return nil + } + clips := *editorObjectClips(&object) + if clips == nil || clips.usesObjectMatrix() || len(clips.Clip) == 0 { + return nil + } + boundary, _ := editorGeometry(object) + box, err := ParseBox(boundary) + if err != nil { + return nil + } + translation := "" + if box.X != 0 || box.Y != 0 { + translation = pdfNumbers(1, 0, 0, 1, -box.X, -box.Y) + } + bytes := 0 + result := &Clips{Clip: make([]Clip, len(clips.Clip))} + for index, clip := range clips.Clip { + if len(clip.Area) != 1 || clip.Area[0].CTM != translation { + return nil + } + area := clip.Area[0] + area.CTM = "" + result.Clip[index].Area = []ClipArea{area} + for _, path := range area.Path { + bytes += len(path.AbbreviatedData) + } + } + if bytes < 2048 { + return nil + } + return result +} + +// pdfMatchesPageClip 比较页面坐标裁剪,不将相同外接矩形视为相同路径 +// 入参: object 导入对象, pageClip 已还原的裁剪区 +// 返回: bool 是否可共享裁剪区 +func pdfMatchesPageClip(object GraphicObject, pageClip *Clips) bool { + if object.Type != "TextObject" && object.Type != "PathObject" && object.Type != "ImageObject" { + return false + } + clips := *editorObjectClips(&object) + if clips == nil || clips.usesObjectMatrix() || len(clips.Clip) != len(pageClip.Clip) { + return false + } + boundary, _ := editorGeometry(object) + box, err := ParseBox(boundary) + if err != nil { + return false + } + translation := "" + if box.X != 0 || box.Y != 0 { + translation = pdfNumbers(1, 0, 0, 1, -box.X, -box.Y) + } + for index, clip := range clips.Clip { + if len(clip.Area) != 1 || clip.Area[0].CTM != translation { + return false + } + area, expected := clip.Area[0], pageClip.Clip[index].Area[0] + if area.DrawParam != expected.DrawParam || !reflect.DeepEqual(area.Path, expected.Path) || !reflect.DeepEqual(area.Text, expected.Text) { + return false + } + } + return true +} + +// clips 省略覆盖对象边界的矩形裁剪,复用其余几何并保留位移及路径交集 +// 入参: paths 裁剪路径, origin 对象边界 +// 返回: *Clips OFD裁剪区域,无裁剪时为空, error 未解析的裁剪字形 +func (p *pdfImporter) clips(paths []pdfgo.Path, origin Box) (*Clips, error) { + if len(paths) == 0 { + return nil, nil + } + var clips *Clips + var areas []ClipArea + translation := "" + for _, path := range paths { + if err := p.ctx.Err(); err != nil { + return nil, err + } + if codecpdf.ClipContainsBoundary(path, p.matrix, origin) { + continue + } + if clips == nil { + clips = &Clips{Clip: make([]Clip, 0, len(paths))} + areas = make([]ClipArea, len(paths)) + if origin.X != 0 || origin.Y != 0 { + translation = pdfNumbers(1, 0, 0, 1, -origin.X, -origin.Y) + } + } + index := len(clips.Clip) + area := &areas[index] + area.CTM = translation + if len(path.Segments) != 0 { + encoded, err := p.clipPath(path) + if err != nil { + return nil, err + } + area.Path = encoded.path[:1:1] + } + for _, mark := range path.Text { + objects, ok := p.clipTexts[mark] + if !ok { + 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 { + return nil, err + } + p.objects.truncate(count) + p.report.TextObjects = textCount + objects = p.clipTexts[mark] + } + if len(path.Text) == 1 { + area.Text = objects[:len(objects):len(objects)] + } else { + area.Text = append(area.Text, objects...) + } + } + if len(area.Path)+len(area.Text) == 0 { + fill, stroke := true, false + area.Path = []PathObject{{Boundary: "0 0 1 1", AbbreviatedData: "M 0 0 C", Fill: &fill, Stroke: &stroke}} + } + clips.Clip = append(clips.Clip, Clip{Area: areas[index : index+1 : index+1]}) + } + return clips, nil +} diff --git a/ofdgo_pdf_group.go b/ofdgo_pdf_group.go index 651725b..e7d5059 100644 --- a/ofdgo_pdf_group.go +++ b/ofdgo_pdf_group.go @@ -155,7 +155,7 @@ func (p *pdfImporter) groupPaths(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) if path.Style.SoftMask != nil || path.Style.BlendMode != "" && path.Style.BlendMode != "Normal" && path.Style.BlendMode != "Compatible" { return &pdfgo.UnsupportedError{Feature: "masked or blended transparent group content"} } - data, err := p.pathDataContext(p.ctx, path.Path, Box{}) + data, err := codecpdf.PathData(p.ctx, path.Path, p.matrix, Box{}) if err != nil { return err } @@ -205,7 +205,7 @@ func (p *pdfImporter) groupPaths(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) return fmt.Errorf("invalid PDF stroke matrix") } local := pdfImporter{matrix: inverse} - data, err = local.pathDataContext(p.ctx, path.Path, Box{}) + data, err = codecpdf.PathData(p.ctx, path.Path, local.matrix, Box{}) if err != nil { return err } @@ -677,7 +677,7 @@ func (p *pdfImporter) maskClip(mask *pdfgo.SoftMask) (pdfgo.Path, error) { if err != nil { return err } - data, err := identity.pathDataContext(p.ctx, mark.Path, Box{}) + data, err := codecpdf.PathData(p.ctx, mark.Path, identity.matrix, Box{}) if err != nil { return err } @@ -693,7 +693,7 @@ func (p *pdfImporter) maskClip(mask *pdfgo.SoftMask) (pdfgo.Path, error) { if len(clip.Text) != 0 { return &pdfgo.UnsupportedError{Feature: "text-clipped binary mask"} } - data, err := identity.pathDataContext(p.ctx, clip, Box{}) + data, err := codecpdf.PathData(p.ctx, clip, identity.matrix, Box{}) if err != nil { return err } diff --git a/ofdgo_pdf_link.go b/ofdgo_pdf_link.go index 22acd8c..9686c73 100644 --- a/ofdgo_pdf_link.go +++ b/ofdgo_pdf_link.go @@ -15,6 +15,7 @@ package ofdgo import ( + "bytes" "context" "errors" "fmt" @@ -24,6 +25,13 @@ import ( "github.com/xiaoqidun/pdfgo" ) +// pdfEmbeddedFile 保存目标路径的容器与原始文件,阅读器按需打开 +type pdfEmbeddedFile struct { + reader *pdfgo.Reader + file *pdfgo.FileSpecification + data []byte +} + // annotations 将链接、静态注解和表单外观按页面顺序转换为OFD对象 // 入参: ctx 取消上下文, page PDF页面, strict 严格检查开关 // 返回: error 错误信息 @@ -575,3 +583,182 @@ func (p *pdfImporter) staticLinkAction(object pdfgo.Object, kind pdfgo.Name, str p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF " + string(kind) + " action not transferred to OFD; static appearance retained"}) return nil, nil } + +// launchLinkAction 保留文件打开目标,不执行应用程序或猜测平台启动参数 +// 入参: object 启动动作, strict 是否禁止启动语义损失 +// 返回: *Action 附件动作, error 解析、取消或无法表达的语义 +func (p *pdfImporter) launchLinkAction(object pdfgo.Object, strict bool) (*Action, error) { + action, err := p.reader.ReadLaunchAction(p.ctx, object) + if err != nil { + return nil, err + } + if action.File == nil || action.Windows != nil || action.Mac != nil || action.Unix != nil { + if strict { + return nil, &pdfgo.UnsupportedError{Feature: "platform launch action conversion"} + } + p.warning(pdfgo.Diagnostic{Message: "PDF platform launch parameters not transferred to OFD; appearance retained"}) + return nil, nil + } + if strict && action.NewWindow == nil { + return nil, &pdfgo.UnsupportedError{Feature: "launch reader window preference conversion"} + } + data, available, err := p.mediaData(p.ctx, *action.File) + if err != nil || !available { + return nil, err + } + pdfTarget := bytes.HasPrefix(bytes.TrimSpace(data[:min(len(data), 1024)]), []byte("%PDF-")) + if !pdfTarget && strict { + return nil, &pdfgo.UnsupportedError{Feature: "application or non-PDF launch action conversion"} + } + id, err := p.attachmentFile(action.File.Name, data) + if err != nil { + return nil, err + } + window := action.NewWindow + if !pdfTarget { + window = nil + p.warning(pdfgo.Diagnostic{Message: "PDF launch target retained as attachment; application execution not transferred to OFD"}) + } else if window == nil { + p.warning(pdfgo.Diagnostic{Message: "PDF launch target retained as attachment; reader window preference not transferred to OFD"}) + } + return &Action{Event: "CLICK", GotoA: &GotoA{AttachID: id, NewWindow: window}}, nil +} + +// remoteLinkAction 保留调用方提供的远程文件,不生成OFD无法表达的文件内目标 +// 入参: object 远程跳转动作, strict 是否禁止目标语义损失 +// 返回: *Action 附件动作, error 结构、取消或转换错误 +func (p *pdfImporter) remoteLinkAction(object pdfgo.Object, strict bool) (*Action, error) { + action, err := p.reader.ReadExternalGoToAction(p.ctx, object) + if err != nil { + return nil, err + } + if strict { + return nil, &pdfgo.UnsupportedError{Feature: "remote document destination conversion"} + } + if p.resolveFile == nil { + p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF remote link file unavailable: " + action.File.Name + "; appearance retained, remote destination not transferred to OFD"}) + return nil, nil + } + data, err := p.reader.ReadFileData(p.ctx, *action.File, p.resolveFile) + if err != nil { + return nil, err + } + id, err := p.attachmentFile(action.File.Name, data) + if err != nil { + return nil, err + } + message := "PDF remote link target retained as attachment; destination inside attached PDF not transferred to OFD" + if action.NewWindow == nil { + message += "; reader window preference not transferred" + } + p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: message}) + return &Action{Event: "CLICK", GotoA: &GotoA{AttachID: id, NewWindow: action.NewWindow}}, nil +} + +// embeddedLinkAction 按目标路径定位附件,回到源文档时保留页内目标 +// 入参: object 嵌入文件动作, strict 是否禁止附件内目标语义损失 +// 返回: *Action 附件动作, pdfgo.Object 源文档目标, error 目标或转换错误 +func (p *pdfImporter) embeddedLinkAction(object pdfgo.Object, strict bool) (*Action, pdfgo.Object, error) { + action, err := p.reader.ReadExternalGoToAction(p.ctx, object) + if err != nil { + return nil, nil, err + } + stack := []pdfEmbeddedFile{{reader: p.reader}} + if action.File != nil { + data, available, err := p.mediaData(p.ctx, *action.File) + if err != nil || !available { + return nil, nil, err + } + stack[0] = pdfEmbeddedFile{file: action.File, data: data} + } + var opened []*pdfgo.Reader + defer func() { + for _, reader := range opened { + reader.Close() + } + }() + open := func(file *pdfEmbeddedFile) error { + if file.reader != nil { + return nil + } + if err := p.ctx.Err(); err != nil { + return err + } + if file.data == nil { + var err error + file.data, err = p.reader.ReadFileData(p.ctx, *file.file, p.resolveFile) + if err != nil { + return err + } + } + reader, err := pdfgo.NewReaderWithOptions(bytes.NewReader(file.data), int64(len(file.data)), pdfgo.ReaderOptions{Warning: p.warning}) + if err != nil { + return err + } + file.reader = reader + opened = append(opened, reader) + return p.ctx.Err() + } + for _, target := range action.Target { + if err := p.ctx.Err(); err != nil { + return nil, nil, err + } + if target.Relation == "P" { + if len(stack) == 1 { + if strict { + return nil, nil, &pdfgo.UnsupportedError{Feature: "embedded link parent document unavailable"} + } + p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF embedded link parent document unavailable; link omitted"}) + return nil, nil, nil + } + stack = stack[:len(stack)-1] + continue + } + current := &stack[len(stack)-1] + if err := open(current); err != nil { + return nil, nil, err + } + file, err := current.reader.ReadEmbeddedTargetFile(p.ctx, target) + if err != nil { + if !strict && errors.Is(err, pdfgo.ErrDestinationNotFound) { + p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: fmt.Sprintf("%v; link omitted", err)}) + return nil, nil, nil + } + return nil, nil, err + } + stack = append(stack, pdfEmbeddedFile{file: &file}) + } + current := &stack[len(stack)-1] + if current.file == nil { + destination, err := current.reader.ResolveRemoteDestination(p.ctx, action.Destination) + if err != nil { + if !strict && errors.Is(err, pdfgo.ErrDestinationNotFound) { + p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: fmt.Sprintf("%v; link omitted", err)}) + return nil, nil, nil + } + return nil, nil, err + } + if action.NewWindow != nil && *action.NewWindow { + if strict { + return nil, nil, &pdfgo.UnsupportedError{Feature: "embedded link to source document in new window"} + } + p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF embedded link new window not transferred to OFD; source document destination retained"}) + } + return nil, append(pdfgo.Array{destination.Page, destination.Mode}, destination.Parameters...), nil + } + if strict { + return nil, nil, &pdfgo.UnsupportedError{Feature: "destination inside attached PDF"} + } + if current.data == nil { + current.data, err = p.reader.ReadFileData(p.ctx, *current.file, p.resolveFile) + if err != nil { + return nil, nil, err + } + } + id, err := p.attachmentFile(current.file.Name, current.data) + if err != nil { + return nil, nil, err + } + p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF embedded link target retained as attachment; destination inside attached PDF not transferred to OFD"}) + return &Action{Event: "CLICK", GotoA: &GotoA{AttachID: id, NewWindow: action.NewWindow}}, nil, nil +} diff --git a/ofdgo_pdf_link_file.go b/ofdgo_pdf_link_file.go deleted file mode 100644 index da6f9bf..0000000 --- a/ofdgo_pdf_link_file.go +++ /dev/null @@ -1,275 +0,0 @@ -// 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 ( - "bytes" - "context" - "crypto/sha256" - "errors" - "fmt" - "strings" - - "github.com/xiaoqidun/pdfgo" -) - -// pdfAttachmentKey 按显示名与原始数据复用PDF附件,不合并名称不同的附件 -type pdfAttachmentKey struct { - name string - digest [32]byte -} - -// pdfEmbeddedFile 保存目标路径的容器与原始文件,阅读器按需打开 -type pdfEmbeddedFile struct { - reader *pdfgo.Reader - file *pdfgo.FileSpecification - data []byte -} - -// documentAttachments 导入文档名称树中的附件,与注解及动作引用共用附件资源 -// 入参: ctx 取消上下文 -// 返回: error 名称树、附件读取或写入错误 -func (p *pdfImporter) documentAttachments(ctx context.Context) error { - return p.reader.WalkEmbeddedFiles(ctx, func(_ string, file pdfgo.FileSpecification) error { - if file.Embedded == nil { - if p.warning == nil { - return fmt.Errorf("PDF embedded file has no embedded data") - } - p.warning(pdfgo.Diagnostic{Message: "PDF embedded file without embedded data ignored"}) - return nil - } - data, err := file.Embedded.DecodeContext(ctx) - if err != nil { - var unsupported *pdfgo.UnsupportedError - if p.warning != nil && errors.As(err, &unsupported) { - p.warning(pdfgo.Diagnostic{Message: "PDF document attachment not transferred to OFD: " + err.Error()}) - return nil - } - return err - } - _, err = p.attachmentFile(file.Name, data) - return err - }) -} - -// launchLinkAction 保留文件打开目标,不执行应用程序或猜测平台启动参数 -// 入参: object 启动动作, strict 是否禁止启动语义损失 -// 返回: *Action 附件动作, error 解析、取消或无法表达的语义 -func (p *pdfImporter) launchLinkAction(object pdfgo.Object, strict bool) (*Action, error) { - action, err := p.reader.ReadLaunchAction(p.ctx, object) - if err != nil { - return nil, err - } - if action.File == nil || action.Windows != nil || action.Mac != nil || action.Unix != nil { - if strict { - return nil, &pdfgo.UnsupportedError{Feature: "platform launch action conversion"} - } - p.warning(pdfgo.Diagnostic{Message: "PDF platform launch parameters not transferred to OFD; appearance retained"}) - return nil, nil - } - if strict && action.NewWindow == nil { - return nil, &pdfgo.UnsupportedError{Feature: "launch reader window preference conversion"} - } - data, available, err := p.mediaData(p.ctx, *action.File) - if err != nil || !available { - return nil, err - } - pdfTarget := bytes.HasPrefix(bytes.TrimSpace(data[:min(len(data), 1024)]), []byte("%PDF-")) - if !pdfTarget && strict { - return nil, &pdfgo.UnsupportedError{Feature: "application or non-PDF launch action conversion"} - } - id, err := p.attachmentFile(action.File.Name, data) - if err != nil { - return nil, err - } - window := action.NewWindow - if !pdfTarget { - window = nil - p.warning(pdfgo.Diagnostic{Message: "PDF launch target retained as attachment; application execution not transferred to OFD"}) - } else if window == nil { - p.warning(pdfgo.Diagnostic{Message: "PDF launch target retained as attachment; reader window preference not transferred to OFD"}) - } - return &Action{Event: "CLICK", GotoA: &GotoA{AttachID: id, NewWindow: window}}, nil -} - -// remoteLinkAction 保留调用方提供的远程文件,不生成OFD无法表达的文件内目标 -// 入参: object 远程跳转动作, strict 是否禁止目标语义损失 -// 返回: *Action 附件动作, error 结构、取消或转换错误 -func (p *pdfImporter) remoteLinkAction(object pdfgo.Object, strict bool) (*Action, error) { - action, err := p.reader.ReadExternalGoToAction(p.ctx, object) - if err != nil { - return nil, err - } - if strict { - return nil, &pdfgo.UnsupportedError{Feature: "remote document destination conversion"} - } - if p.resolveFile == nil { - p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF remote link file unavailable: " + action.File.Name + "; appearance retained, remote destination not transferred to OFD"}) - return nil, nil - } - data, err := p.reader.ReadFileData(p.ctx, *action.File, p.resolveFile) - if err != nil { - return nil, err - } - id, err := p.attachmentFile(action.File.Name, data) - if err != nil { - return nil, err - } - message := "PDF remote link target retained as attachment; destination inside attached PDF not transferred to OFD" - if action.NewWindow == nil { - message += "; reader window preference not transferred" - } - p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: message}) - return &Action{Event: "CLICK", GotoA: &GotoA{AttachID: id, NewWindow: action.NewWindow}}, nil -} - -// embeddedLinkAction 按目标路径定位附件,回到源文档时保留页内目标 -// 入参: object 嵌入文件动作, strict 是否禁止附件内目标语义损失 -// 返回: *Action 附件动作, pdfgo.Object 源文档目标, error 目标或转换错误 -func (p *pdfImporter) embeddedLinkAction(object pdfgo.Object, strict bool) (*Action, pdfgo.Object, error) { - action, err := p.reader.ReadExternalGoToAction(p.ctx, object) - if err != nil { - return nil, nil, err - } - stack := []pdfEmbeddedFile{{reader: p.reader}} - if action.File != nil { - data, available, err := p.mediaData(p.ctx, *action.File) - if err != nil || !available { - return nil, nil, err - } - stack[0] = pdfEmbeddedFile{file: action.File, data: data} - } - var opened []*pdfgo.Reader - defer func() { - for _, reader := range opened { - reader.Close() - } - }() - open := func(file *pdfEmbeddedFile) error { - if file.reader != nil { - return nil - } - if err := p.ctx.Err(); err != nil { - return err - } - if file.data == nil { - var err error - file.data, err = p.reader.ReadFileData(p.ctx, *file.file, p.resolveFile) - if err != nil { - return err - } - } - reader, err := pdfgo.NewReaderWithOptions(bytes.NewReader(file.data), int64(len(file.data)), pdfgo.ReaderOptions{Warning: p.warning}) - if err != nil { - return err - } - file.reader = reader - opened = append(opened, reader) - return p.ctx.Err() - } - for _, target := range action.Target { - if err := p.ctx.Err(); err != nil { - return nil, nil, err - } - if target.Relation == "P" { - if len(stack) == 1 { - if strict { - return nil, nil, &pdfgo.UnsupportedError{Feature: "embedded link parent document unavailable"} - } - p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF embedded link parent document unavailable; link omitted"}) - return nil, nil, nil - } - stack = stack[:len(stack)-1] - continue - } - current := &stack[len(stack)-1] - if err := open(current); err != nil { - return nil, nil, err - } - file, err := current.reader.ReadEmbeddedTargetFile(p.ctx, target) - if err != nil { - if !strict && errors.Is(err, pdfgo.ErrDestinationNotFound) { - p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: fmt.Sprintf("%v; link omitted", err)}) - return nil, nil, nil - } - return nil, nil, err - } - stack = append(stack, pdfEmbeddedFile{file: &file}) - } - current := &stack[len(stack)-1] - if current.file == nil { - destination, err := current.reader.ResolveRemoteDestination(p.ctx, action.Destination) - if err != nil { - if !strict && errors.Is(err, pdfgo.ErrDestinationNotFound) { - p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: fmt.Sprintf("%v; link omitted", err)}) - return nil, nil, nil - } - return nil, nil, err - } - if action.NewWindow != nil && *action.NewWindow { - if strict { - return nil, nil, &pdfgo.UnsupportedError{Feature: "embedded link to source document in new window"} - } - p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF embedded link new window not transferred to OFD; source document destination retained"}) - } - return nil, append(pdfgo.Array{destination.Page, destination.Mode}, destination.Parameters...), nil - } - if strict { - return nil, nil, &pdfgo.UnsupportedError{Feature: "destination inside attached PDF"} - } - if current.data == nil { - current.data, err = p.reader.ReadFileData(p.ctx, *current.file, p.resolveFile) - if err != nil { - return nil, nil, err - } - } - id, err := p.attachmentFile(current.file.Name, current.data) - if err != nil { - return nil, nil, err - } - p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF embedded link target retained as attachment; destination inside attached PDF not transferred to OFD"}) - return &Action{Event: "CLICK", GotoA: &GotoA{AttachID: id, NewWindow: action.NewWindow}}, nil, nil -} - -// attachmentFile 按原名及数据复用附件,兼容末尾NUL,缺名使用固定名称 -// 入参: name 附件显示名, data 原始文件数据 -// 返回: string 附件标识, error 附件写入错误 -func (p *pdfImporter) attachmentFile(name string, data []byte) (string, error) { - if name == "" { - name = "Attachment" - } - key := pdfAttachmentKey{name: name, digest: sha256.Sum256(data)} - if id, ok := p.attachmentIDs[key]; ok { - return id, nil - } - if p.warning != nil { - name = strings.TrimRight(name, "\x00") - if name == "" { - name = "Attachment" - } - } - id, err := p.editor.AddAttachment(name, data) - if err != nil { - return "", err - } - if p.attachmentIDs == nil { - p.attachmentIDs = make(map[pdfAttachmentKey]string) - } - p.attachmentIDs[key] = id - if name != key.name { - p.warning(pdfgo.Diagnostic{Message: "PDF attachment name trailing NUL removed; file data retained"}) - } - return id, nil -} diff --git a/ofdgo_pdf_media.go b/ofdgo_pdf_media.go index 80d5635..7df31db 100644 --- a/ofdgo_pdf_media.go +++ b/ofdgo_pdf_media.go @@ -16,6 +16,7 @@ package ofdgo import ( "context" + "crypto/sha256" "errors" "fmt" "math" @@ -27,9 +28,6 @@ import ( "github.com/xiaoqidun/pdfgo" ) -// errPDFMediaRead 标识媒体读取失败,避免作为可恢复的导航问题忽略 -var errPDFMediaRead = errors.New("PDF media read failed") - // pdfMediaFileKey 区分媒体文件及注册用途,避免重复解码或调用文件解析器 type pdfMediaFileKey struct { Stream *pdfgo.Stream @@ -55,6 +53,15 @@ type pdfMediaInstanceKey struct { Resource string } +// pdfAttachmentKey 按显示名与原始数据复用PDF附件,不合并名称不同的附件 +type pdfAttachmentKey struct { + name string + digest [32]byte +} + +// errPDFMediaRead 标识媒体读取失败,避免作为可恢复的导航问题忽略 +var errPDFMediaRead = errors.New("PDF media read failed") + // mediaInstance 为播放目标分配独立标识,同一目标的动作继续共用控制状态 // 入参: id 已注册的媒体资源, target 目标注解, page 所属页面索引 // 返回: string 独立资源标识, error 资源复制或取消错误 @@ -313,3 +320,258 @@ func (p *pdfImporter) mediaData(ctx context.Context, file pdfgo.FileSpecificatio } return data, true, nil } + +// movieResource 保存视频原始内容,按用途复用媒体或附件资源 +// 入参: ctx 取消上下文, file 文件说明, attachment 是否作为附件保存 +// 返回: string 媒体或附件标识,缺少外部资源时为空, error 读取、注册或取消错误 +func (p *pdfImporter) movieResource(ctx context.Context, file pdfgo.FileSpecification, attachment bool) (string, error) { + return p.mediaResource(ctx, file, "Video", codecpdf.MediaFormat(file), attachment) +} + +// movieAnnotation 保留视频外观及可表示的播放动作,无法等价转换时按策略处理 +// 入参: ctx 取消上下文, page PDF页面, annotation 视频注解, strict 是否禁止语义损失 +// 返回: error 媒体、外观、播放参数或取消错误 +func (p *pdfImporter) movieAnnotation(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation, strict bool) error { + if err := ctx.Err(); err != nil { + return err + } + movie, err := p.reader.ReadMovie(annotation.Dictionary["Movie"]) + if err != nil { + return err + } + activation, err := p.reader.Resolve(annotation.Dictionary["A"]) + if err != nil { + return err + } + if activation == pdfgo.Boolean(false) { + return p.appearanceAnnotation(ctx, page, annotation) + } + if activation == pdfgo.Boolean(true) { + activation = nil + } + parameters, err := p.reader.ReadMovieActivation(ctx, activation) + if err != nil { + return err + } + if !pdfMoviePlaybackSupported(movie, parameters) { + if err := p.moviePlaybackLoss(ctx, movie.File, strict); err != nil { + return err + } + return p.appearanceAnnotation(ctx, page, annotation) + } + if parameters.FloatingScale != nil { + if err := p.movieActionLoss("movie playback window", strict, true); err != nil { + return err + } + } + id, err := p.movieResource(ctx, movie.File, false) + if err != nil { + return err + } + if id == "" { + return p.appearanceAnnotation(ctx, page, annotation) + } + id, err = p.mediaInstance(id, annotation, p.page) + if err != nil { + return err + } + return p.appearanceAnnotation(ctx, page, annotation, Action{Event: "CLICK", Movie: &Movie{ResourceID: id, Operator: "Play"}}) +} + +// pdfMoviePlaybackSupported 判断OFD视频动作能否保持播放内容、声音及控制方式 +// 入参: movie 视频信息, parameters 播放参数 +// 返回: bool 是否可直接转换,不含播放窗口差异 +func pdfMoviePlaybackSupported(movie pdfgo.Movie, parameters pdfgo.MovieActivation) bool { + return (parameters.Start == nil || parameters.Start.Value == 0) && parameters.Duration == nil && parameters.Rate == 1 && parameters.Volume == 1 && !parameters.ShowControls && !parameters.Synchronous && parameters.Mode == "Once" && movie.Rotate == 0 +} + +// movieLinkAction 转换当前页视频操作,不将指定播放区间或速度改为完整默认播放 +// 入参: object PDF视频动作, source 触发注解, strict 是否禁止语义损失, currentPage 动作链当前页 +// 返回: *Action OFD视频动作,不能安全转换时为空, error 参数、资源或严格模式错误 +func (p *pdfImporter) movieLinkAction(object pdfgo.Object, source pdfgo.Annotation, strict bool, currentPage int) (*Action, error) { + if currentPage < 0 || currentPage >= len(p.pageOrder) { + if _, err := p.reader.ReadMovieAction(p.ctx, object); err != nil { + return nil, err + } + return nil, p.movieActionLoss("movie target without a fixed current page", strict, false) + } + playback, err := p.reader.ReadMoviePlayback(p.ctx, p.pageOrder[currentPage], object) + if err != nil { + if !strict && errors.Is(err, pdfgo.ErrInvalidAction) { + p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("invalid PDF movie action ignored: %v", err)}) + return nil, nil + } + return nil, err + } + if playback.Operation == "Play" { + parameters := playback.Activation + if !pdfMoviePlaybackSupported(playback.Movie, parameters) { + return nil, p.moviePlaybackLoss(p.ctx, playback.Movie.File, strict) + } + if parameters.FloatingScale != nil { + if err := p.movieActionLoss("movie playback window", strict, true); err != nil { + return nil, err + } + } + } + id, err := p.movieResource(p.ctx, playback.Movie.File, false) + if err != nil || id == "" { + return nil, err + } + id, err = p.mediaInstance(id, playback.Annotation, currentPage) + if err != nil { + return nil, err + } + action := &Action{Event: "CLICK", Movie: &Movie{ResourceID: id, Operator: string(playback.Operation)}} + if playback.Operation == "Play" && playback.Activation.FloatingScale == nil { + if err := p.moviePlaybackBounds(action.Movie, source, playback.Annotation, currentPage, strict); err != nil { + return nil, err + } + } + return action, nil +} + +// moviePlaybackBounds 保存同页嵌入视频的播放区域,不改变触发注解的点击区域 +// 入参: movie OFD视频动作, source 触发注解, target 播放注解, currentPage 当前页, strict 是否禁止语义损失 +// 返回: error 播放区域、标志或严格模式错误 +func (p *pdfImporter) moviePlaybackBounds(movie *Movie, source, target pdfgo.Annotation, currentPage int, strict bool) error { + if source.Subtype == "" || currentPage != p.page { + return p.movieActionLoss("movie playback window", strict, true) + } + if source.Rect == target.Rect { + return nil + } + flags, err := p.reader.ReadAnnotationFlags(target) + if err != nil { + return err + } + if flags&(8|16) != 0 { + return p.movieActionLoss("movie playback window scaling", strict, true) + } + box := pdfBounds([]pdfgo.Point{ + p.matrix.Apply(pdfgo.Point{X: target.Rect.XMin, Y: target.Rect.YMin}), + p.matrix.Apply(pdfgo.Point{X: target.Rect.XMax, Y: target.Rect.YMin}), + p.matrix.Apply(pdfgo.Point{X: target.Rect.XMax, Y: target.Rect.YMax}), + p.matrix.Apply(pdfgo.Point{X: target.Rect.XMin, Y: target.Rect.YMax}), + }) + if box.W <= 0 || box.H <= 0 { + return p.movieActionLoss("empty movie playback window", strict, true) + } + if p.movieBounds == nil { + p.movieBounds = make(map[*Movie]Box) + } + p.movieBounds[movie] = box + return nil +} + +// annotationMovieBounds 校验同一动作链的播放区域,保持单一图元上的动作顺序 +// 入参: actions 注解动作, source 触发区域, flags 触发注解标志 +// 返回: Box 播放区域, error 无法等价转换的窗口要求 +func (p *pdfImporter) annotationMovieBounds(actions []Action, source Box, flags int64) (Box, error) { + playback, found := source, false + for _, action := range actions { + if action.Movie == nil || action.Movie.Operator != "" && action.Movie.Operator != "Play" { + continue + } + box, ok := p.movieBounds[action.Movie] + if !ok { + box = source + } + if found && playback != box || box != source && flags&(8|16) != 0 { + return source, p.movieActionLoss("multiple or fixed-scale movie playback windows", p.warning == nil, true) + } + playback, found = box, true + } + return playback, nil +} + +// moviePlaybackLoss 保留不可等价转换的视频文件,不生成改变播放内容的动作 +// 入参: ctx 取消上下文, file 视频文件说明, strict 是否禁止语义损失 +// 返回: error 严格模式、资源读取或取消错误 +func (p *pdfImporter) moviePlaybackLoss(ctx context.Context, file pdfgo.FileSpecification, strict bool) error { + if strict { + return &pdfgo.UnsupportedError{Feature: "movie playback parameters conversion"} + } + id, err := p.movieResource(ctx, file, true) + if err != nil { + return err + } + message := "PDF movie playback parameters not transferred to OFD; action omitted" + if id != "" { + message += "; original media retained as attachment" + } + p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: message}) + return nil +} + +// movieActionLoss 按策略报告OFD无法表达的视频参数,不自行添加扩展属性 +// 入参: feature 差异内容, strict 严格模式, retained 是否保留视频操作 +// 返回: error 严格模式下的不可等价转换错误 +func (p *pdfImporter) movieActionLoss(feature string, strict, retained bool) error { + if strict { + return &pdfgo.UnsupportedError{Feature: feature + " conversion"} + } + suffix := "; action omitted" + if retained { + suffix = "; video operation retained" + } + p.warning(pdfgo.Diagnostic{Message: "PDF " + feature + " not transferred to OFD" + suffix}) + return nil +} + +// documentAttachments 导入文档名称树中的附件,与注解及动作引用共用附件资源 +// 入参: ctx 取消上下文 +// 返回: error 名称树、附件读取或写入错误 +func (p *pdfImporter) documentAttachments(ctx context.Context) error { + return p.reader.WalkEmbeddedFiles(ctx, func(_ string, file pdfgo.FileSpecification) error { + if file.Embedded == nil { + if p.warning == nil { + return fmt.Errorf("PDF embedded file has no embedded data") + } + p.warning(pdfgo.Diagnostic{Message: "PDF embedded file without embedded data ignored"}) + return nil + } + data, err := file.Embedded.DecodeContext(ctx) + if err != nil { + var unsupported *pdfgo.UnsupportedError + if p.warning != nil && errors.As(err, &unsupported) { + p.warning(pdfgo.Diagnostic{Message: "PDF document attachment not transferred to OFD: " + err.Error()}) + return nil + } + return err + } + _, err = p.attachmentFile(file.Name, data) + return err + }) +} + +// attachmentFile 按原名及数据复用附件,兼容末尾NUL,缺名使用固定名称 +// 入参: name 附件显示名, data 原始文件数据 +// 返回: string 附件标识, error 附件写入错误 +func (p *pdfImporter) attachmentFile(name string, data []byte) (string, error) { + if name == "" { + name = "Attachment" + } + key := pdfAttachmentKey{name: name, digest: sha256.Sum256(data)} + if id, ok := p.attachmentIDs[key]; ok { + return id, nil + } + if p.warning != nil { + name = strings.TrimRight(name, "\x00") + if name == "" { + name = "Attachment" + } + } + id, err := p.editor.AddAttachment(name, data) + if err != nil { + return "", err + } + if p.attachmentIDs == nil { + p.attachmentIDs = make(map[pdfAttachmentKey]string) + } + p.attachmentIDs[key] = id + if name != key.name { + p.warning(pdfgo.Diagnostic{Message: "PDF attachment name trailing NUL removed; file data retained"}) + } + return id, nil +} diff --git a/ofdgo_pdf_metadata.go b/ofdgo_pdf_metadata.go deleted file mode 100644 index 9025279..0000000 --- a/ofdgo_pdf_metadata.go +++ /dev/null @@ -1,352 +0,0 @@ -// 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_movie.go b/ofdgo_pdf_movie.go deleted file mode 100644 index c393317..0000000 --- a/ofdgo_pdf_movie.go +++ /dev/null @@ -1,222 +0,0 @@ -// 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" - "errors" - "fmt" - - codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf" - "github.com/xiaoqidun/pdfgo" -) - -// movieResource 保存视频原始内容,按用途复用媒体或附件资源 -// 入参: ctx 取消上下文, file 文件说明, attachment 是否作为附件保存 -// 返回: string 媒体或附件标识,缺少外部资源时为空, error 读取、注册或取消错误 -func (p *pdfImporter) movieResource(ctx context.Context, file pdfgo.FileSpecification, attachment bool) (string, error) { - return p.mediaResource(ctx, file, "Video", codecpdf.MediaFormat(file), attachment) -} - -// movieAnnotation 保留视频外观及可表示的播放动作,无法等价转换时按策略处理 -// 入参: ctx 取消上下文, page PDF页面, annotation 视频注解, strict 是否禁止语义损失 -// 返回: error 媒体、外观、播放参数或取消错误 -func (p *pdfImporter) movieAnnotation(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation, strict bool) error { - if err := ctx.Err(); err != nil { - return err - } - movie, err := p.reader.ReadMovie(annotation.Dictionary["Movie"]) - if err != nil { - return err - } - activation, err := p.reader.Resolve(annotation.Dictionary["A"]) - if err != nil { - return err - } - if activation == pdfgo.Boolean(false) { - return p.appearanceAnnotation(ctx, page, annotation) - } - if activation == pdfgo.Boolean(true) { - activation = nil - } - parameters, err := p.reader.ReadMovieActivation(ctx, activation) - if err != nil { - return err - } - if !pdfMoviePlaybackSupported(movie, parameters) { - if err := p.moviePlaybackLoss(ctx, movie.File, strict); err != nil { - return err - } - return p.appearanceAnnotation(ctx, page, annotation) - } - if parameters.FloatingScale != nil { - if err := p.movieActionLoss("movie playback window", strict, true); err != nil { - return err - } - } - id, err := p.movieResource(ctx, movie.File, false) - if err != nil { - return err - } - if id == "" { - return p.appearanceAnnotation(ctx, page, annotation) - } - id, err = p.mediaInstance(id, annotation, p.page) - if err != nil { - return err - } - return p.appearanceAnnotation(ctx, page, annotation, Action{Event: "CLICK", Movie: &Movie{ResourceID: id, Operator: "Play"}}) -} - -// pdfMoviePlaybackSupported 判断OFD视频动作能否保持播放内容、声音及控制方式 -// 入参: movie 视频信息, parameters 播放参数 -// 返回: bool 是否可直接转换,不含播放窗口差异 -func pdfMoviePlaybackSupported(movie pdfgo.Movie, parameters pdfgo.MovieActivation) bool { - return (parameters.Start == nil || parameters.Start.Value == 0) && parameters.Duration == nil && parameters.Rate == 1 && parameters.Volume == 1 && !parameters.ShowControls && !parameters.Synchronous && parameters.Mode == "Once" && movie.Rotate == 0 -} - -// movieLinkAction 转换当前页视频操作,不将指定播放区间或速度改为完整默认播放 -// 入参: object PDF视频动作, source 触发注解, strict 是否禁止语义损失, currentPage 动作链当前页 -// 返回: *Action OFD视频动作,不能安全转换时为空, error 参数、资源或严格模式错误 -func (p *pdfImporter) movieLinkAction(object pdfgo.Object, source pdfgo.Annotation, strict bool, currentPage int) (*Action, error) { - if currentPage < 0 || currentPage >= len(p.pageOrder) { - if _, err := p.reader.ReadMovieAction(p.ctx, object); err != nil { - return nil, err - } - return nil, p.movieActionLoss("movie target without a fixed current page", strict, false) - } - playback, err := p.reader.ReadMoviePlayback(p.ctx, p.pageOrder[currentPage], object) - if err != nil { - if !strict && errors.Is(err, pdfgo.ErrInvalidAction) { - p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("invalid PDF movie action ignored: %v", err)}) - return nil, nil - } - return nil, err - } - if playback.Operation == "Play" { - parameters := playback.Activation - if !pdfMoviePlaybackSupported(playback.Movie, parameters) { - return nil, p.moviePlaybackLoss(p.ctx, playback.Movie.File, strict) - } - if parameters.FloatingScale != nil { - if err := p.movieActionLoss("movie playback window", strict, true); err != nil { - return nil, err - } - } - } - id, err := p.movieResource(p.ctx, playback.Movie.File, false) - if err != nil || id == "" { - return nil, err - } - id, err = p.mediaInstance(id, playback.Annotation, currentPage) - if err != nil { - return nil, err - } - action := &Action{Event: "CLICK", Movie: &Movie{ResourceID: id, Operator: string(playback.Operation)}} - if playback.Operation == "Play" && playback.Activation.FloatingScale == nil { - if err := p.moviePlaybackBounds(action.Movie, source, playback.Annotation, currentPage, strict); err != nil { - return nil, err - } - } - return action, nil -} - -// moviePlaybackBounds 保存同页嵌入视频的播放区域,不改变触发注解的点击区域 -// 入参: movie OFD视频动作, source 触发注解, target 播放注解, currentPage 当前页, strict 是否禁止语义损失 -// 返回: error 播放区域、标志或严格模式错误 -func (p *pdfImporter) moviePlaybackBounds(movie *Movie, source, target pdfgo.Annotation, currentPage int, strict bool) error { - if source.Subtype == "" || currentPage != p.page { - return p.movieActionLoss("movie playback window", strict, true) - } - if source.Rect == target.Rect { - return nil - } - flags, err := p.reader.ReadAnnotationFlags(target) - if err != nil { - return err - } - if flags&(8|16) != 0 { - return p.movieActionLoss("movie playback window scaling", strict, true) - } - box := pdfBounds([]pdfgo.Point{ - p.matrix.Apply(pdfgo.Point{X: target.Rect.XMin, Y: target.Rect.YMin}), - p.matrix.Apply(pdfgo.Point{X: target.Rect.XMax, Y: target.Rect.YMin}), - p.matrix.Apply(pdfgo.Point{X: target.Rect.XMax, Y: target.Rect.YMax}), - p.matrix.Apply(pdfgo.Point{X: target.Rect.XMin, Y: target.Rect.YMax}), - }) - if box.W <= 0 || box.H <= 0 { - return p.movieActionLoss("empty movie playback window", strict, true) - } - if p.movieBounds == nil { - p.movieBounds = make(map[*Movie]Box) - } - p.movieBounds[movie] = box - return nil -} - -// annotationMovieBounds 校验同一动作链的播放区域,保持单一图元上的动作顺序 -// 入参: actions 注解动作, source 触发区域, flags 触发注解标志 -// 返回: Box 播放区域, error 无法等价转换的窗口要求 -func (p *pdfImporter) annotationMovieBounds(actions []Action, source Box, flags int64) (Box, error) { - playback, found := source, false - for _, action := range actions { - if action.Movie == nil || action.Movie.Operator != "" && action.Movie.Operator != "Play" { - continue - } - box, ok := p.movieBounds[action.Movie] - if !ok { - box = source - } - if found && playback != box || box != source && flags&(8|16) != 0 { - return source, p.movieActionLoss("multiple or fixed-scale movie playback windows", p.warning == nil, true) - } - playback, found = box, true - } - return playback, nil -} - -// moviePlaybackLoss 保留不可等价转换的视频文件,不生成改变播放内容的动作 -// 入参: ctx 取消上下文, file 视频文件说明, strict 是否禁止语义损失 -// 返回: error 严格模式、资源读取或取消错误 -func (p *pdfImporter) moviePlaybackLoss(ctx context.Context, file pdfgo.FileSpecification, strict bool) error { - if strict { - return &pdfgo.UnsupportedError{Feature: "movie playback parameters conversion"} - } - id, err := p.movieResource(ctx, file, true) - if err != nil { - return err - } - message := "PDF movie playback parameters not transferred to OFD; action omitted" - if id != "" { - message += "; original media retained as attachment" - } - p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: message}) - return nil -} - -// movieActionLoss 按策略报告OFD无法表达的视频参数,不自行添加扩展属性 -// 入参: feature 差异内容, strict 严格模式, retained 是否保留视频操作 -// 返回: error 严格模式下的不可等价转换错误 -func (p *pdfImporter) movieActionLoss(feature string, strict, retained bool) error { - if strict { - return &pdfgo.UnsupportedError{Feature: feature + " conversion"} - } - suffix := "; action omitted" - if retained { - suffix = "; video operation retained" - } - p.warning(pdfgo.Diagnostic{Message: "PDF " + feature + " not transferred to OFD" + suffix}) - return nil -} diff --git a/ofdgo_pdf_navigation.go b/ofdgo_pdf_navigation.go deleted file mode 100644 index 1d0d543..0000000 --- a/ofdgo_pdf_navigation.go +++ /dev/null @@ -1,227 +0,0 @@ -// 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 ( - "bytes" - "context" - "encoding/xml" - "errors" - "fmt" - "math" - "strconv" - - codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf" - "github.com/xiaoqidun/pdfgo" -) - -// pdfDestinationFontBackend 禁止内容度量忽略实际绘制所需的缺失字体 -type pdfDestinationFontBackend struct { - FontBackend -} - -// ResolveFont 沿用字体匹配,只在实际绘制缺少字体数据时中止度量 -// 入参: renderer 度量渲染器, id 字体标识, exact 是否禁止无关回退 -// 返回: ResolvedFont 字体资源, error 解析或缺失错误 -func (b pdfDestinationFontBackend) ResolveFont(renderer *Renderer, id string, exact bool) (ResolvedFont, error) { - font, err := b.FontBackend.ResolveFont(renderer, id, exact) - if err == nil && len(font.Data) == 0 { - err = fmt.Errorf("PDF destination font %q unavailable", id) - } - return font, err -} - -// destinationBounds 度量目标页内容并缓存页面内范围,不包含注解或像素取整 -// 入参: page PDF目标页 -// 返回: Box OFD内容范围, error 度量错误 -func (p *pdfImporter) destinationBounds(page *pdfgo.Page) (Box, error) { - if err := p.ctx.Err(); err != nil { - return Box{}, err - } - if box, ok := p.destinationBoxes[page.Reference]; ok { - return box, nil - } - if index, ok := p.pageIndexes[page.Reference]; ok { - previous := p.page - p.page = index - defer func() { p.page = previous }() - } - matrix, width, height := codecpdf.PageMatrix(page) - editor := NewEditor() - editor.SetRenderBackends(p.editor.Backends()) - editor.SetFontDirs(p.editor.fontDirs...) - editor.SetFontFS(p.editor.fontFS...) - editor.fontSourcesCache = p.renderer.fontSourcesCache - editor.validatedPaths = p.editor.pathValidationCache() - if _, err := editor.AddPage(width, height); err != nil { - return Box{}, err - } - local := pdfImporter{ - ctx: p.ctx, reader: p.reader, editor: editor, renderer: p.renderer, - matrix: matrix, pageBox: page.CropBox, pageWidth: width, pageHeight: height, - rasterDPI: p.rasterDPI, fontIDs: make(map[*pdfgo.Font]string), - resolveFile: p.resolveFile, resolveReference: p.resolveReference, - references: &codecpdf.ReferenceCache{}, - halftones: p.halftones, halftoneWarnings: make(map[*pdfgo.Halftone]bool), - } - defer local.references.Close() - if err := local.compositePage(nil, func(visitor pdfgo.Visitor) error { - return p.reader.WalkPage(p.ctx, page, visitor) - }); err != nil { - return Box{}, fmt.Errorf("PDF destination content: %w", err) - } - if err := local.commitObjects(); err != nil { - return Box{}, err - } - reader, err := editor.resourceReader() - if err != nil { - return Box{}, err - } - defer reader.Close() - renderer := p.renderer.childRenderer(reader) - renderer.TransparentBackground = true - renderer.RenderAnnotations = false - if renderer.backends.Fonts != nil { - renderer.backends.Fonts = pdfDestinationFontBackend{renderer.backends.Fonts} - } - scene, err := renderer.CompilePageContext(p.ctx, &editor.pages[0]) - if err != nil { - return Box{}, err - } - if scene == nil { - return Box{}, fmt.Errorf("PDF destination content scene unavailable") - } - geometry, err := renderer.Geometry() - if err != nil { - return Box{}, err - } - box, err := scene.BoundsContext(p.ctx, geometry) - if err != nil { - return Box{}, err - } - left, top := math.Max(0, box.X), math.Max(0, box.Y) - right, bottom := math.Min(width, box.X+box.W), math.Min(height, box.Y+box.H) - box = Box{X: left, Y: top, W: right - left, H: bottom - top} - if !finite(left) || !finite(top) || !finite(right) || !finite(bottom) || box.W <= 0 || box.H <= 0 { - return Box{}, fmt.Errorf("PDF destination content bounds empty or invalid") - } - if err := p.ctx.Err(); err != nil { - return Box{}, err - } - if p.destinationBoxes == nil { - p.destinationBoxes = make(map[pdfgo.Reference]Box) - } - p.destinationBoxes[page.Reference] = box - return box, nil -} - -// outlines 按先序批量转换目录,保留层级、展开状态和动作顺序 -// 入参: ctx 取消上下文 -// 返回: error 严格模式转换错误或取消错误 -func (p *pdfImporter) outlines(ctx context.Context) error { - count := 0 - data, err := encodeOFDXMLContext(ctx, func(x *ofdXML) { - x.root("Outlines", nil) - depth := 0 - err := p.reader.WalkOutlines(ctx, func(level int, item pdfgo.OutlineItem) error { - actions, err := p.outlineActions(ctx, item) - if err != nil { - if p.warning == nil || ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errPDFMediaRead) { - return err - } - p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outline %q actions not transferred to OFD: %v", item.Title, err)}) - actions = nil - } - for depth > level { - x.end("OutlineElem") - depth-- - } - attrs := ofdAttrs{{Name: xml.Name{Local: "Title"}, Value: item.Title}} - attrs.add("Expanded", strconv.FormatBool(item.Expanded)) - x.start("OutlineElem", attrs) - x.actions(actions) - depth++ - count++ - return x.err - }) - if err != nil { - x.err = err - return - } - for depth > 0 { - x.end("OutlineElem") - depth-- - } - x.end("Outlines") - }) - if err != nil { - if ctx.Err() != nil { - return ctx.Err() - } - if p.warning == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errPDFMediaRead) { - return err - } - p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outlines not transferred to OFD: %v", err)}) - return nil - } - if count > 0 { - p.editor.outlines = bytes.TrimPrefix(data, []byte(xml.Header)) - } - return nil -} - -// outlineActions 转换目录目标及动作链,不为无目标节点补充跳转 -// 入参: ctx 取消上下文, item PDF目录节点 -// 返回: []Action OFD动作序列, error 动作或取消错误 -func (p *pdfImporter) outlineActions(ctx context.Context, item pdfgo.OutlineItem) ([]Action, error) { - object, err := p.reader.Resolve(item.Action) - if err != nil { - return nil, err - } - dest, err := p.reader.Resolve(item.Destination) - if err != nil { - return nil, err - } - if dest != nil { - if object != nil { - return nil, fmt.Errorf("PDF outline has both destination and action") - } - object = pdfgo.Dictionary{"S": pdfgo.Name("GoTo"), "D": dest} - } - currentPage := -1 - var actions []Action - err = p.reader.WalkActions(ctx, object, func(info pdfgo.ActionInfo) error { - converted, err := p.linkActions(pdfgo.Annotation{Dictionary: pdfgo.Dictionary{"A": info.Dictionary}}, p.warning == nil, ¤tPage) - if err != nil { - if errors.Is(err, errPDFMediaRead) { - return err - } - var unsupported *pdfgo.UnsupportedError - if p.warning != nil && (errors.As(err, &unsupported) || errors.Is(err, pdfgo.ErrInvalidDestination) || errors.Is(err, pdfgo.ErrInvalidAction)) { - p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outline action not transferred to OFD: %v", err)}) - currentPage = -1 - return nil - } - return err - } - if converted != nil { - actions = append(actions, converted...) - } else { - currentPage = -1 - } - return nil - }) - return actions, err -} diff --git a/ofdgo_pdf_structure.go b/ofdgo_pdf_structure.go index 17482d6..7946e17 100644 --- a/ofdgo_pdf_structure.go +++ b/ofdgo_pdf_structure.go @@ -17,6 +17,7 @@ package ofdgo import ( "context" "encoding/base64" + "encoding/xml" "fmt" "maps" "math" @@ -24,6 +25,7 @@ import ( "strconv" "strings" + codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf" "github.com/xiaoqidun/pdfgo" ) @@ -33,6 +35,22 @@ const ( pdfStructureNamespace = "https://ofdgo.aite.me/ns/pdf/structure/1" ) +// 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" +) + // pdfStructureBindings 保存来源区段与最终页面图元的关联,不记录临时渲染对象 type pdfStructureBindings struct { parents map[*pdfgo.Reader]map[[2]int64]pdfgo.Reference @@ -93,6 +111,12 @@ type pdfStructureValues struct { objects []StructureNode } +// pdfMetadataValue 保留字段是否声明及其来源,空文本不作为缺省值 +type pdfMetadataValue struct { + text string + xmp bool +} + // prepareStructure 校验结构内容关联并登记引用区段,不提前写入标引 // 入参: ctx 取消上下文 // 返回: error 结构或内容容器错误 @@ -882,3 +906,277 @@ func (b *pdfStructureBindings) reference(pageID string, sequence *pdfgo.ContentS b.sequences[sequence] = refs return &refs[len(refs)-1] } + +// 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 := codecpdf.MetadataDate(stamp.text, stamp.xmp) + 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 := codecpdf.MetadataDate(value.text, value.xmp) + 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 +} diff --git a/ofdgo_pdf_text.go b/ofdgo_pdf_text.go index 23ba965..b14431d 100644 --- a/ofdgo_pdf_text.go +++ b/ofdgo_pdf_text.go @@ -15,16 +15,48 @@ package ofdgo import ( + "context" + "crypto/sha256" "fmt" "math" "slices" "strconv" + "strings" "unicode/utf8" + basefont "github.com/xiaoqidun/ofdgo/internal/base/font" codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf" "github.com/xiaoqidun/pdfgo" ) +// pdfImportedFont 保存单次导入共用的字体资源、度量及源字形映射,不保存文档标识 +type pdfImportedFont struct { + resource *FontResource + checksum [32]byte + metrics FontMetrics + type1Glyphs map[string]uint16 + repairLimit uint16 +} + +// pdfTextReplacement 记录一个完整替换区段的文字数量与转换状态 +type pdfTextReplacement struct { + text *pdfgo.ReplacementText + count int + matched int + offset int64 + blocked bool + applied bool + identity bool +} + +// pdfTextReplacements 按绘制顺序收集替换区段,避免拆分或重复写入原文 +type pdfTextReplacements struct { + importer *pdfImporter + spans map[*pdfgo.ReplacementText]*pdfTextReplacement + order []*pdfTextReplacement + widget bool +} + // text 保留字形及逐字基线,外部字体优先复用同名内嵌资源 // 入参: mark PDF文字绘制信息 // 返回: error 错误信息 @@ -509,3 +541,463 @@ func (p *pdfImporter) disjointText(node pdfCompositeNode) (bool, error) { cache.textDisjoint[node.group] = true return true, nil } + +// shapePDFText 使用导入字体整形注释文字,将毫米坐标约定转换为PDF文字空间方向 +// 入参: ctx 取消上下文, source 源字体, text 单行原文, rightToLeft 段落方向 +// 返回: []pdfgo.ShapedGlyph 千分之一字号的定位字形, error 字体、整形或取消错误 +func (p *pdfImporter) shapePDFText(ctx context.Context, source *pdfgo.Font, text string, rightToLeft bool) ([]pdfgo.ShapedGlyph, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if len(source.Program) == 0 { + return nil, &pdfgo.UnsupportedError{Feature: "annotation shaping without embedded font"} + } + font, err := p.importedFont(source) + if err != nil { + return nil, err + } + shaper, ok := font.metrics.(FontShaperOptions) + if !ok { + return nil, &pdfgo.UnsupportedError{Feature: "annotation text shaping backend"} + } + options := TextShapeOptions{Bidi: true} + if rightToLeft { + options.Direction = "rtl" + } + glyphs, err := shaper.ShapeTextWithOptions(text, 1000, options) + if err != nil { + return nil, err + } + result := make([]pdfgo.ShapedGlyph, len(glyphs)) + units := float64(font.metrics.UnitsPerEm()) + if units <= 0 { + return nil, fmt.Errorf("invalid annotation font units") + } + for i, glyph := range glyphs { + if err := ctx.Err(); err != nil { + return nil, err + } + result[i] = pdfgo.ShapedGlyph{ID: glyph.Glyph, Cluster: glyph.Cluster, VisualOrder: glyph.VisualOrder, X: glyph.X, Y: -glyph.Y, Advance: glyph.Advance, Width: float64(font.metrics.GlyphAdvance(glyph.Glyph)) * 1000 / units} + } + return result, ctx.Err() +} + +// embeddedTextFont 匹配同名内嵌字体,仅复用唯一且覆盖全部字符的字形程序 +// 入参: source 外部字体, glyphs 当前文字字形 +// 返回: *pdfImportedFont 可复用字体,无可靠匹配时为空 +func (p *pdfImporter) embeddedTextFont(source *pdfgo.Font, glyphs []pdfgo.Glyph) *pdfImportedFont { + name := codecpdf.EmbeddedFontName(source.Name) + if name == "" || len(glyphs) == 0 { + return nil + } + var matched *pdfImportedFont + for _, imported := range p.fonts { + if codecpdf.EmbeddedFontName(imported.resource.Font.FontName) != name { + continue + } + if matched != nil && matched.checksum != imported.checksum { + return nil + } + matched = imported + } + if matched == nil { + return nil + } + for _, glyph := range glyphs { + if glyph.Name == ".notdef" || utf8.RuneCountInString(glyph.Text) != 1 { + return nil + } + char, _ := utf8.DecodeRuneInString(glyph.Text) + id := matched.metrics.GlyphIndex(char) + if id == 0 || id >= matched.metrics.NumGlyphs() || matched.repairLimit != 0 && id >= matched.repairLimit { + return nil + } + } + return matched +} + +// importedFont 按原PDF字体复用包装和解析结果,失败结果不进入缓存 +// 入参: source 源字体 +// 返回: *pdfImportedFont 只读导入字体, error 字体程序或后端错误 +func (p *pdfImporter) importedFont(source *pdfgo.Font) (*pdfImportedFont, error) { + if cached := p.fonts[source]; cached != nil { + return cached, nil + } + var type1 *basefont.Type1Program + result := &pdfImportedFont{} + if source.ProgramType == "FontFile" { + parsed, err := basefont.ParseType1Program(source.Program) + if err != nil { + return nil, fmt.Errorf("PDF font %s program: %w", source.Name, err) + } + type1 = &parsed + result.type1Glyphs = parsed.GlyphIDs() + } + program, repair, err := codecpdf.FontProgram(source, type1) + if err != nil { + return nil, fmt.Errorf("PDF font %s program: %w", source.Name, err) + } + if repair.Locations && p.warning == nil { + return nil, fmt.Errorf("PDF font %s program: %w", source.Name, basefont.ErrTTInvalidLocations) + } + if p.editor.backends.FontResources == nil { + return nil, fmt.Errorf("PDF font %s resource: font resources: %w", source.Name, ErrBackendUnavailable) + } + result.resource, err = p.editor.backends.FontResources.OpenFontResource(FontFile{Name: source.Name + ".ttf", Data: program}, 0) + if err != nil { + return nil, fmt.Errorf("PDF font %s resource: %w", source.Name, err) + } + if p.editor.backends.Fonts == nil { + return nil, fmt.Errorf("PDF font backend unavailable") + } + result.metrics, err = p.editor.backends.Fonts.OpenFont(result.resource.Data) + if err != nil { + return nil, fmt.Errorf("PDF font %s metrics: %w", source.Name, err) + } + if _, ok := result.metrics.(FontOutlines); !ok { + return nil, fmt.Errorf("PDF font backend does not provide glyph outlines") + } + result.checksum, result.repairLimit = sha256.Sum256(result.resource.Data), repair.MetricsLimit + if repair.Locations { + p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF font %s has invalid glyph locations; index rebuilt with original glyph IDs", source.Name)}) + } + if p.fonts == nil { + p.fonts = make(map[*pdfgo.Font]*pdfImportedFont) + } + p.fonts[source] = result + return result, nil +} + +// symbolFont 加载标准符号字体,保留原字形编号和源字形名称 +// 入参: source 未嵌入的PDF字体 +// 返回: *pdfImportedFont 可用的符号字体,无匹配时为空, error 字体或取消错误 +func (p *pdfImporter) symbolFont(source *pdfgo.Font) (*pdfImportedFont, error) { + name := codecpdf.EmbeddedFontName(source.Name) + if source.Subtype != "Type1" || name != "Symbol" && name != "ZapfDingbats" { + return nil, nil + } + if imported, ok := p.symbolFonts[source]; ok { + return imported, nil + } + if p.symbolFonts == nil { + p.symbolFonts = make(map[*pdfgo.Font]*pdfImportedFont) + } + if err := p.ctx.Err(); err != nil { + return nil, err + } + definition := &Font{ID: name, FontName: name, FamilyName: name} + reader := &Reader{doc: &Document{}, fontResourcesRead: true, fontCache: map[string]*Font{name: definition}} + resolved, err := p.editor.newRenderer(reader).ResolveFont(name, true) + if err != nil || len(resolved.Data) == 0 || p.editor.backends.FontResources == nil { + p.symbolFonts[source] = nil + return nil, nil + } + program, names, err := basefont.SymbolProgram(p.ctx, resolved.Data, name) + if err != nil { + return nil, err + } + if len(names) == 0 { + p.symbolFonts[source] = nil + return nil, nil + } + resource, err := p.editor.backends.FontResources.OpenFontResource(FontFile{Name: name + ".ttf", Data: program}, 0) + if err != nil { + return nil, err + } + metrics, err := p.editor.backends.Fonts.OpenFont(resource.Data) + if err != nil { + return nil, err + } + if _, ok := metrics.(FontOutlines); !ok { + p.symbolFonts[source] = nil + return nil, nil + } + result := &pdfImportedFont{resource: resource, checksum: sha256.Sum256(resource.Data), metrics: metrics, type1Glyphs: names} + p.symbolFonts[source] = result + return result, nil +} + +// span 查找最外层替换区段,相邻区段即使原文相同也分别保留 +// 入参: text 替换文本 +// 返回: *pdfTextReplacement 区段状态,未声明时为空 +func (r *pdfTextReplacements) span(text *pdfgo.ReplacementText) *pdfTextReplacement { + if text == nil { + return nil + } + if r.spans == nil { + r.spans = make(map[*pdfgo.ReplacementText]*pdfTextReplacement) + } + first := text + for text.Parent != nil && r.spans[text] == nil { + text = text.Parent + } + span := r.spans[text] + if span == nil { + span = &pdfTextReplacement{text: text} + r.spans[text] = span + r.order = append(r.order, span) + } + for first != text { + r.spans[first] = span + first = first.Parent + } + return span +} + +// block 标记无法仅用字符与字形变换表达的区段 +// 入参: text 替换文本 +func (r *pdfTextReplacements) block(text *pdfgo.ReplacementText) { + if span := r.span(text); span != nil { + span.blocked = true + } +} + +// 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 := mark.Reader.Resolve(mark.Properties["Type"]) + if err != nil { + return err + } + if kind == pdfgo.Name("OCG") || kind == pdfgo.Name("OCMD") { + return nil + } + } + if r.widget && mark.Tag == "Tx" && len(mark.Properties) == 0 { + return nil + } + if mark.Replacement != nil && len(mark.Properties) == 1 { + return nil + } + if r.importer.warning == nil { + return &pdfgo.UnsupportedError{Feature: "marked content requiring semantic preservation"} + } + r.importer.warning(pdfgo.Diagnostic{Offset: mark.Offset, Message: "marked content " + string(mark.Tag) + " not preserved"}) + return nil +} + +// apply 合并可等价表示的替换区段,原文相同但样式不同的区段保留原图元 +// 入参: nodes 原始图元 +// 返回: []pdfCompositeNode 转换图元, error 取消或语义转换错误 +func (r *pdfTextReplacements) apply(nodes []pdfCompositeNode) ([]pdfCompositeNode, error) { + if len(r.order) == 0 { + return nodes, nil + } + if err := r.count(nodes, false); err != nil { + return nil, err + } + for _, span := range r.order { + span.identity = !span.blocked && span.count > 0 && span.matched == len(span.text.Text) + } + var err error + nodes, err = r.merge(nodes) + if err != nil { + return nil, err + } + for _, span := range r.order { + if span.applied || span.identity || !span.blocked && span.count == 0 && span.text.Text == "" { + continue + } + if r.importer.warning == nil { + return nil, &pdfgo.UnsupportedError{Feature: "ActualText span without equivalent OFD glyph mapping"} + } + r.importer.warning(pdfgo.Diagnostic{Offset: span.offset, Message: "PDF ActualText span not preserved; original appearance retained"}) + } + r.clear(nodes) + return nodes, nil +} + +// count 统计区段文字并逐字核对原文,排除需要组级合成的内容 +// 入参: nodes 图元, blocked 上层组是否阻止直接转换 +// 返回: error 取消错误 +func (r *pdfTextReplacements) count(nodes []pdfCompositeNode, blocked bool) error { + for _, node := range nodes { + if err := r.importer.ctx.Err(); err != nil { + return err + } + if node.text != nil { + if span := r.span(node.text.Replacement); span != nil { + span.count++ + span.blocked = span.blocked || blocked + for i, glyph := range node.text.Glyphs { + if span.matched < 0 { + break + } + if i%256 == 0 { + if err := r.importer.ctx.Err(); err != nil { + return err + } + } + if glyph.Text == "" || !strings.HasPrefix(span.text.Text[span.matched:], glyph.Text) { + span.matched = -1 + break + } + span.matched += len(glyph.Text) + } + } + } + childBlocked := blocked + if group := node.group; group != nil && node.textObject == nil { + opaque, err := r.importer.opaque(node, group.ColorSpace) + if err := r.importer.ctx.Err(); err != nil { + return err + } + childBlocked = childBlocked || !opaque || err != nil || group.ColorSpace != nil && !group.ColorSpace.SRGBEquivalent() + } + if err := r.count(node.children, childBlocked); err != nil { + return err + } + } + return nil +} + +// merge 合并同层连续图元,保留原始绘制顺序与字形坐标 +// 入参: nodes 图元 +// 返回: []pdfCompositeNode 合并结果, error 取消错误 +func (r *pdfTextReplacements) merge(nodes []pdfCompositeNode) ([]pdfCompositeNode, error) { + result := nodes[:0] + for i := 0; i < len(nodes); { + if err := r.importer.ctx.Err(); err != nil { + return nil, err + } + node := nodes[i] + var err error + node.children, err = r.merge(node.children) + if err != nil { + return nil, err + } + end := i + 1 + combined := false + first := node.text + if first == nil && node.textObject != nil && len(node.children) != 0 { + first = node.children[0].text + } + if first != nil { + if span := r.span(first.Replacement); span != nil && !span.applied && !span.blocked && span.text.Text != "" && !(span.identity && span.count == 1) { + var marks []*pdfgo.TextMark + for end = i; end < len(nodes); end++ { + current := nodes[end] + children := current.children + if current.text != nil { + children = nodes[end : end+1] + } else if current.textObject == nil { + break + } + valid := len(children) != 0 + for _, child := range children { + if child.text == nil || r.span(child.text.Replacement) != span { + valid = false + break + } + } + if !valid { + break + } + for _, child := range children { + marks = append(marks, child.text) + } + } + end = max(end, i+1) + if len(marks) == span.count { + if mark, ok := codecpdf.MergeReplacement(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) + 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 { + node.children = []pdfCompositeNode{{text: mark}} + } + span.applied = true + combined = true + } + } + } + } + node.formCount = 0 + result = append(result, node) + if !combined { + for _, remaining := range nodes[i+1 : end] { + remaining.formCount = 0 + result = append(result, remaining) + } + } + i = end + } + clear(nodes[len(result):]) + return result, nil +} + +// clear 清除无需替换及未转换的区段引用,防止局部绘制重复使用整段原文 +// 入参: nodes 图元 +func (r *pdfTextReplacements) clear(nodes []pdfCompositeNode) { + for _, node := range nodes { + if node.text != nil { + if span := r.span(node.text.Replacement); span != nil && !span.applied { + node.text.Replacement = nil + } + } + r.clear(node.children) + } +} + +// 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} +} + +// pdfApplyReplacement 将整段原文映射到全部字形,字形位移遵循GB/T 33190的文字定位规则 +// 入参: object 文字对象, text 整段原文, positions 原始字形位置 +func pdfApplyReplacement(object *TextObject, text string, positions []pdfgo.Point) { + const unit = 25.4 / 72 + glyphs := make([]string, len(object.CGTransform)) + for i, transform := range object.CGTransform { + glyphs[i] = transform.Glyphs + } + dx, dy := make([]float64, len(positions)-1), make([]float64, len(positions)-1) + for i := 1; i < len(positions); i++ { + dx[i-1] = (positions[i].X - positions[i-1].X) * unit + dy[i-1] = -(positions[i].Y - positions[i-1].Y) * unit + } + object.TextCode = []TextCode{{X: pdfNumbers(positions[0].X * unit), Y: pdfNumbers(-positions[0].Y * unit), DeltaX: pdfNumbers(dx...), DeltaY: pdfNumbers(dy...), Value: escapeOFDText(text)}} + object.CGTransform = []CGTransform{{CodeCount: utf8.RuneCountInString(text), GlyphCount: len(glyphs), Glyphs: strings.Join(glyphs, " ")}} +} diff --git a/ofdgo_pdf_text_replacement.go b/ofdgo_pdf_text_replacement.go deleted file mode 100644 index 43f918b..0000000 --- a/ofdgo_pdf_text_replacement.go +++ /dev/null @@ -1,409 +0,0 @@ -// 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 ( - "math" - "reflect" - "strings" - "unicode/utf8" - - codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf" - "github.com/xiaoqidun/pdfgo" -) - -// pdfTextReplacement 记录一个完整替换区段的文字数量与转换状态 -type pdfTextReplacement struct { - text *pdfgo.ReplacementText - count int - matched int - offset int64 - blocked bool - applied bool - identity bool -} - -// pdfTextReplacements 按绘制顺序收集替换区段,避免拆分或重复写入原文 -type pdfTextReplacements struct { - importer *pdfImporter - spans map[*pdfgo.ReplacementText]*pdfTextReplacement - order []*pdfTextReplacement - widget bool -} - -// span 查找最外层替换区段,相邻区段即使原文相同也分别保留 -// 入参: text 替换文本 -// 返回: *pdfTextReplacement 区段状态,未声明时为空 -func (r *pdfTextReplacements) span(text *pdfgo.ReplacementText) *pdfTextReplacement { - if text == nil { - return nil - } - if r.spans == nil { - r.spans = make(map[*pdfgo.ReplacementText]*pdfTextReplacement) - } - first := text - for text.Parent != nil && r.spans[text] == nil { - text = text.Parent - } - span := r.spans[text] - if span == nil { - span = &pdfTextReplacement{text: text} - r.spans[text] = span - r.order = append(r.order, span) - } - for first != text { - r.spans[first] = span - first = first.Parent - } - return span -} - -// block 标记无法仅用字符与字形变换表达的区段 -// 入参: text 替换文本 -func (r *pdfTextReplacements) block(text *pdfgo.ReplacementText) { - if span := r.span(text); span != nil { - span.blocked = true - } -} - -// 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 := mark.Reader.Resolve(mark.Properties["Type"]) - if err != nil { - return err - } - if kind == pdfgo.Name("OCG") || kind == pdfgo.Name("OCMD") { - return nil - } - } - if r.widget && mark.Tag == "Tx" && len(mark.Properties) == 0 { - return nil - } - if mark.Replacement != nil && len(mark.Properties) == 1 { - return nil - } - if r.importer.warning == nil { - return &pdfgo.UnsupportedError{Feature: "marked content requiring semantic preservation"} - } - r.importer.warning(pdfgo.Diagnostic{Offset: mark.Offset, Message: "marked content " + string(mark.Tag) + " not preserved"}) - return nil -} - -// apply 合并可等价表示的替换区段,原文相同但样式不同的区段保留原图元 -// 入参: nodes 原始图元 -// 返回: []pdfCompositeNode 转换图元, error 取消或语义转换错误 -func (r *pdfTextReplacements) apply(nodes []pdfCompositeNode) ([]pdfCompositeNode, error) { - if len(r.order) == 0 { - return nodes, nil - } - if err := r.count(nodes, false); err != nil { - return nil, err - } - for _, span := range r.order { - span.identity = !span.blocked && span.count > 0 && span.matched == len(span.text.Text) - } - var err error - nodes, err = r.merge(nodes) - if err != nil { - return nil, err - } - for _, span := range r.order { - if span.applied || span.identity || !span.blocked && span.count == 0 && span.text.Text == "" { - continue - } - if r.importer.warning == nil { - return nil, &pdfgo.UnsupportedError{Feature: "ActualText span without equivalent OFD glyph mapping"} - } - r.importer.warning(pdfgo.Diagnostic{Offset: span.offset, Message: "PDF ActualText span not preserved; original appearance retained"}) - } - r.clear(nodes) - return nodes, nil -} - -// count 统计区段文字并逐字核对原文,排除需要组级合成的内容 -// 入参: nodes 图元, blocked 上层组是否阻止直接转换 -// 返回: error 取消错误 -func (r *pdfTextReplacements) count(nodes []pdfCompositeNode, blocked bool) error { - for _, node := range nodes { - if err := r.importer.ctx.Err(); err != nil { - return err - } - if node.text != nil { - if span := r.span(node.text.Replacement); span != nil { - span.count++ - span.blocked = span.blocked || blocked - for i, glyph := range node.text.Glyphs { - if span.matched < 0 { - break - } - if i%256 == 0 { - if err := r.importer.ctx.Err(); err != nil { - return err - } - } - if glyph.Text == "" || !strings.HasPrefix(span.text.Text[span.matched:], glyph.Text) { - span.matched = -1 - break - } - span.matched += len(glyph.Text) - } - } - } - childBlocked := blocked - if group := node.group; group != nil && node.textObject == nil { - opaque, err := r.importer.opaque(node, group.ColorSpace) - if err := r.importer.ctx.Err(); err != nil { - return err - } - childBlocked = childBlocked || !opaque || err != nil || group.ColorSpace != nil && !group.ColorSpace.SRGBEquivalent() - } - if err := r.count(node.children, childBlocked); err != nil { - return err - } - } - return nil -} - -// merge 合并同层连续图元,保留原始绘制顺序与字形坐标 -// 入参: nodes 图元 -// 返回: []pdfCompositeNode 合并结果, error 取消错误 -func (r *pdfTextReplacements) merge(nodes []pdfCompositeNode) ([]pdfCompositeNode, error) { - result := nodes[:0] - for i := 0; i < len(nodes); { - if err := r.importer.ctx.Err(); err != nil { - return nil, err - } - node := nodes[i] - var err error - node.children, err = r.merge(node.children) - if err != nil { - return nil, err - } - end := i + 1 - combined := false - first := node.text - if first == nil && node.textObject != nil && len(node.children) != 0 { - first = node.children[0].text - } - if first != nil { - if span := r.span(first.Replacement); span != nil && !span.applied && !span.blocked && span.text.Text != "" && !(span.identity && span.count == 1) { - var marks []pdfCompositeNode - for end = i; end < len(nodes); end++ { - current := nodes[end] - children := current.children - if current.text != nil { - children = nodes[end : end+1] - } else if current.textObject == nil { - break - } - valid := len(children) != 0 - for _, child := range children { - if child.text == nil || r.span(child.text.Replacement) != span { - valid = false - break - } - } - if !valid { - break - } - marks = append(marks, children...) - } - 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 { - node.children = []pdfCompositeNode{{text: mark}} - } - span.applied = true - combined = true - } - } - } - } - node.formCount = 0 - result = append(result, node) - if !combined { - for _, remaining := range nodes[i+1 : end] { - remaining.formCount = 0 - result = append(result, remaining) - } - } - i = end - } - clear(nodes[len(result):]) - return result, nil -} - -// clear 清除无需替换及未转换的区段引用,防止局部绘制重复使用整段原文 -// 入参: nodes 图元 -func (r *pdfTextReplacements) clear(nodes []pdfCompositeNode) { - for _, node := range nodes { - if node.text != nil { - if span := r.span(node.text.Replacement); span != nil && !span.applied { - node.text.Replacement = nil - } - } - r.clear(node.children) - } -} - -// 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 是否可等价合并 -func pdfMergeReplacement(nodes []pdfCompositeNode, replacement *pdfgo.ReplacementText) (*pdfgo.TextMark, bool) { - first := nodes[0].text - style := first.Style - paint := style.Fill - if first.Font == nil || len(first.Font.Program) == 0 || first.Mode != 0 || first.Size <= 0 || first.HorizontalScale <= 0 || first.Clip != nil || style.SoftMask != nil || style.Transfer != nil || style.AlphaIsShape || style.FillOverprint || paint.Alpha != 1 || !codecpdf.NormalBlend(style.BlendMode) || paint.Tiling != nil || paint.Axial != nil || paint.Radial != nil || paint.Mesh != nil || paint.Function != nil { - return nil, false - } - inverse, ok := first.Matrix.Inverse() - if !ok { - 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) { - return nil, false - } - if mark.Matrix[0] != first.Matrix[0] || mark.Matrix[1] != first.Matrix[1] || mark.Matrix[2] != first.Matrix[2] || mark.Matrix[3] != first.Matrix[3] { - 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 { - mark := node.text - dx, dy := mark.Matrix[4]-first.Matrix[4], mark.Matrix[5]-first.Matrix[5] - offset := pdfgo.Point{X: inverse[0]*dx + inverse[2]*dy, Y: inverse[1]*dx + inverse[3]*dy} - for _, point := range mark.Positions { - point.X += offset.X - point.Y += offset.Y - if math.IsNaN(point.X) || math.IsNaN(point.Y) || math.IsInf(point.X, 0) || math.IsInf(point.Y, 0) { - return nil, false - } - merged.Positions = append(merged.Positions, point) - } - merged.Glyphs = append(merged.Glyphs, mark.Glyphs...) - } - 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) { - const unit = 25.4 / 72 - glyphs := make([]string, len(object.CGTransform)) - for i, transform := range object.CGTransform { - glyphs[i] = transform.Glyphs - } - dx, dy := make([]float64, len(positions)-1), make([]float64, len(positions)-1) - for i := 1; i < len(positions); i++ { - dx[i-1] = (positions[i].X - positions[i-1].X) * unit - dy[i-1] = -(positions[i].Y - positions[i-1].Y) * unit - } - object.TextCode = []TextCode{{X: pdfNumbers(positions[0].X * unit), Y: pdfNumbers(-positions[0].Y * unit), DeltaX: pdfNumbers(dx...), DeltaY: pdfNumbers(dy...), Value: escapeOFDText(text)}} - object.CGTransform = []CGTransform{{CodeCount: utf8.RuneCountInString(text), GlyphCount: len(glyphs), Glyphs: strings.Join(glyphs, " ")}} -}