feat(文件归并): 归并PDF封装并下沉计算逻辑
build.yaml / build (push) Successful in 45s

This commit is contained in:
xiaoqidun committed 2026-10-19 10:10:27 +08:00
1 parent 84808cda61
commit ec24b6a17d
17 files changed
+1908 -2026

No files matched your search

+66
View File
@@ -17,6 +17,8 @@ package pdf
import ( import (
"context" "context"
"fmt" "fmt"
"math"
"strconv"
basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom" basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom"
"github.com/xiaoqidun/pdfgo" "github.com/xiaoqidun/pdfgo"
@@ -99,3 +101,67 @@ func GlyphPath(outline basegeom.Path, matrix pdfgo.Matrix) (pdfgo.Path, error) {
} }
return path, nil 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
}
+51
View File
@@ -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")
}
+102
View File
@@ -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
}
+204
View File
@@ -15,13 +15,23 @@
package ofdgo package ofdgo
import ( import (
"bytes"
"context" "context"
"encoding/xml"
"errors" "errors"
"fmt" "fmt"
"math"
"strconv"
codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf"
"github.com/xiaoqidun/pdfgo" "github.com/xiaoqidun/pdfgo"
) )
// pdfDestinationFontBackend 禁止内容度量忽略实际绘制所需的缺失字体
type pdfDestinationFontBackend struct {
FontBackend
}
// documentOpenActions 转换文档初始目标与打开动作,不执行脚本或打开资源 // documentOpenActions 转换文档初始目标与打开动作,不执行脚本或打开资源
// 入参: ctx 取消上下文 // 入参: ctx 取消上下文
// 返回: error 动作、编码或取消错误 // 返回: error 动作、编码或取消错误
@@ -207,3 +217,197 @@ func (p *pdfImporter) annotationTriggerActions(ctx context.Context, annotation p
} }
return converted, ctx.Err() 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, &currentPage)
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
}
-258
View File
@@ -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
}
-210
View File
@@ -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
}
+239 -66
View File
@@ -21,6 +21,7 @@ import (
"fmt" "fmt"
"image/color" "image/color"
"math" "math"
"reflect"
"strconv" "strconv"
basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom" basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom"
@@ -34,6 +35,13 @@ type pdfPointBounds struct {
found bool found bool
} }
// pdfClipPath 保存导入器只读的裁剪几何,不缓存对象位移或文字裁剪
type pdfClipPath struct {
box Box
data string
path *[1]PathObject
}
// pdfGeometryPath 转换PDF路径并衔接OFD几何类型 // pdfGeometryPath 转换PDF路径并衔接OFD几何类型
// 入参: ctx 取消上下文, source PDF路径, matrix 坐标变换 // 入参: ctx 取消上下文, source PDF路径, matrix 坐标变换
// 返回: GeometryPath 独立几何路径, error 指令、坐标或取消错误 // 返回: 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)} 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 保留每次路径绘制操作,重叠描边不合并为单次绘制 // path 保留每次路径绘制操作,重叠描边不合并为单次绘制
// 入参: mark PDF路径绘制信息 // 入参: mark PDF路径绘制信息
// 返回: error 错误信息 // 返回: error 错误信息
@@ -383,7 +327,7 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error {
} }
return p.gradientPath(mark) 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 { if err != nil {
return err return err
} }
@@ -447,7 +391,7 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error {
if err != nil { if err != nil {
return err 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 { if err != nil {
return err return err
} }
@@ -465,3 +409,232 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error {
p.report.PathObjects++ p.report.PathObjects++
return nil 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
}
+4 -4
View File
@@ -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" { 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"} 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 { if err != nil {
return err return err
} }
@@ -205,7 +205,7 @@ func (p *pdfImporter) groupPaths(mark pdfgo.GroupMark, walk func(pdfgo.Visitor)
return fmt.Errorf("invalid PDF stroke matrix") return fmt.Errorf("invalid PDF stroke matrix")
} }
local := pdfImporter{matrix: inverse} 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 { if err != nil {
return err return err
} }
@@ -677,7 +677,7 @@ func (p *pdfImporter) maskClip(mask *pdfgo.SoftMask) (pdfgo.Path, error) {
if err != nil { if err != nil {
return err 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 { if err != nil {
return err return err
} }
@@ -693,7 +693,7 @@ func (p *pdfImporter) maskClip(mask *pdfgo.SoftMask) (pdfgo.Path, error) {
if len(clip.Text) != 0 { if len(clip.Text) != 0 {
return &pdfgo.UnsupportedError{Feature: "text-clipped binary mask"} 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 { if err != nil {
return err return err
} }
+187
View File
@@ -15,6 +15,7 @@
package ofdgo package ofdgo
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"fmt" "fmt"
@@ -24,6 +25,13 @@ import (
"github.com/xiaoqidun/pdfgo" "github.com/xiaoqidun/pdfgo"
) )
// pdfEmbeddedFile 保存目标路径的容器与原始文件,阅读器按需打开
type pdfEmbeddedFile struct {
reader *pdfgo.Reader
file *pdfgo.FileSpecification
data []byte
}
// annotations 将链接、静态注解和表单外观按页面顺序转换为OFD对象 // annotations 将链接、静态注解和表单外观按页面顺序转换为OFD对象
// 入参: ctx 取消上下文, page PDF页面, strict 严格检查开关 // 入参: ctx 取消上下文, page PDF页面, strict 严格检查开关
// 返回: error 错误信息 // 返回: 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"}) p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF " + string(kind) + " action not transferred to OFD; static appearance retained"})
return nil, nil 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
}
-275
View File
@@ -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
}
+265 -3
View File
@@ -16,6 +16,7 @@ package ofdgo
import ( import (
"context" "context"
"crypto/sha256"
"errors" "errors"
"fmt" "fmt"
"math" "math"
@@ -27,9 +28,6 @@ import (
"github.com/xiaoqidun/pdfgo" "github.com/xiaoqidun/pdfgo"
) )
// errPDFMediaRead 标识媒体读取失败,避免作为可恢复的导航问题忽略
var errPDFMediaRead = errors.New("PDF media read failed")
// pdfMediaFileKey 区分媒体文件及注册用途,避免重复解码或调用文件解析器 // pdfMediaFileKey 区分媒体文件及注册用途,避免重复解码或调用文件解析器
type pdfMediaFileKey struct { type pdfMediaFileKey struct {
Stream *pdfgo.Stream Stream *pdfgo.Stream
@@ -55,6 +53,15 @@ type pdfMediaInstanceKey struct {
Resource string Resource string
} }
// pdfAttachmentKey 按显示名与原始数据复用PDF附件,不合并名称不同的附件
type pdfAttachmentKey struct {
name string
digest [32]byte
}
// errPDFMediaRead 标识媒体读取失败,避免作为可恢复的导航问题忽略
var errPDFMediaRead = errors.New("PDF media read failed")
// mediaInstance 为播放目标分配独立标识,同一目标的动作继续共用控制状态 // mediaInstance 为播放目标分配独立标识,同一目标的动作继续共用控制状态
// 入参: id 已注册的媒体资源, target 目标注解, page 所属页面索引 // 入参: id 已注册的媒体资源, target 目标注解, page 所属页面索引
// 返回: string 独立资源标识, error 资源复制或取消错误 // 返回: string 独立资源标识, error 资源复制或取消错误
@@ -313,3 +320,258 @@ func (p *pdfImporter) mediaData(ctx context.Context, file pdfgo.FileSpecificatio
} }
return data, true, nil 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
}
-352
View File
@@ -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")
}
-222
View File
@@ -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
}
-227
View File
@@ -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, &currentPage)
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
}
+298
View File
@@ -17,6 +17,7 @@ package ofdgo
import ( import (
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/xml"
"fmt" "fmt"
"maps" "maps"
"math" "math"
@@ -24,6 +25,7 @@ import (
"strconv" "strconv"
"strings" "strings"
codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf"
"github.com/xiaoqidun/pdfgo" "github.com/xiaoqidun/pdfgo"
) )
@@ -33,6 +35,22 @@ const (
pdfStructureNamespace = "https://ofdgo.aite.me/ns/pdf/structure/1" 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 保存来源区段与最终页面图元的关联,不记录临时渲染对象 // pdfStructureBindings 保存来源区段与最终页面图元的关联,不记录临时渲染对象
type pdfStructureBindings struct { type pdfStructureBindings struct {
parents map[*pdfgo.Reader]map[[2]int64]pdfgo.Reference parents map[*pdfgo.Reader]map[[2]int64]pdfgo.Reference
@@ -93,6 +111,12 @@ type pdfStructureValues struct {
objects []StructureNode objects []StructureNode
} }
// pdfMetadataValue 保留字段是否声明及其来源,空文本不作为缺省值
type pdfMetadataValue struct {
text string
xmp bool
}
// prepareStructure 校验结构内容关联并登记引用区段,不提前写入标引 // prepareStructure 校验结构内容关联并登记引用区段,不提前写入标引
// 入参: ctx 取消上下文 // 入参: ctx 取消上下文
// 返回: error 结构或内容容器错误 // 返回: error 结构或内容容器错误
@@ -882,3 +906,277 @@ func (b *pdfStructureBindings) reference(pageID string, sequence *pdfgo.ContentS
b.sequences[sequence] = refs b.sequences[sequence] = refs
return &refs[len(refs)-1] 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
}
+492
View File
@@ -15,16 +15,48 @@
package ofdgo package ofdgo
import ( import (
"context"
"crypto/sha256"
"fmt" "fmt"
"math" "math"
"slices" "slices"
"strconv" "strconv"
"strings"
"unicode/utf8" "unicode/utf8"
basefont "github.com/xiaoqidun/ofdgo/internal/base/font"
codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf" codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf"
"github.com/xiaoqidun/pdfgo" "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 保留字形及逐字基线,外部字体优先复用同名内嵌资源 // text 保留字形及逐字基线,外部字体优先复用同名内嵌资源
// 入参: mark PDF文字绘制信息 // 入参: mark PDF文字绘制信息
// 返回: error 错误信息 // 返回: error 错误信息
@@ -509,3 +541,463 @@ func (p *pdfImporter) disjointText(node pdfCompositeNode) (bool, error) {
cache.textDisjoint[node.group] = true cache.textDisjoint[node.group] = true
return true, nil 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, " ")}}
}
-409
View File
@@ -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, " ")}}
}