mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 08:46:55 +08:00
1 parent
84808cda61
commit
ec24b6a17d
17 files changed
+1908
-2026
No files matched your search
@@ -17,6 +17,8 @@ package pdf
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom"
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
@@ -99,3 +101,67 @@ func GlyphPath(outline basegeom.Path, matrix pdfgo.Matrix) (pdfgo.Path, error) {
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// PathBounds 逐段计算变换后的控制点边界,不创建临时点集合
|
||||
// 入参: ctx 取消上下文, path PDF路径, matrix 坐标变换
|
||||
// 返回: basegeom.Box 控制点边界,空路径返回零值, error 取消错误
|
||||
func PathBounds(ctx context.Context, path pdfgo.Path, matrix pdfgo.Matrix) (basegeom.Box, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return basegeom.Box{}, err
|
||||
}
|
||||
minX, minY, maxX, maxY := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1)
|
||||
found := false
|
||||
for _, segment := range path.Segments {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return basegeom.Box{}, err
|
||||
}
|
||||
for index, point := range segment.Points {
|
||||
if index&255 == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return basegeom.Box{}, err
|
||||
}
|
||||
}
|
||||
point = matrix.Apply(point)
|
||||
minX, minY = math.Min(minX, point.X), math.Min(minY, point.Y)
|
||||
maxX, maxY = math.Max(maxX, point.X), math.Max(maxY, point.Y)
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return basegeom.Box{}, err
|
||||
}
|
||||
if !found {
|
||||
return basegeom.Box{}, nil
|
||||
}
|
||||
return basegeom.Box{X: minX, Y: minY, W: math.Max(maxX-minX, 1e-6), H: math.Max(maxY-minY, 1e-6)}, nil
|
||||
}
|
||||
|
||||
// PathData 转换路径并在分段处理时检查取消
|
||||
// 入参: ctx 取消上下文, path PDF路径, matrix 坐标变换, origin 对象边界
|
||||
// 返回: string OFD路径数据, error 取消错误
|
||||
func PathData(ctx context.Context, path pdfgo.Path, matrix pdfgo.Matrix, origin basegeom.Box) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
data := make([]byte, 0, len(path.Segments)*48)
|
||||
for index, segment := range path.Segments {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if index != 0 {
|
||||
data = append(data, ' ')
|
||||
}
|
||||
data = append(data, segment.Operator...)
|
||||
for _, point := range segment.Points {
|
||||
point = matrix.Apply(point)
|
||||
data = append(data, ' ')
|
||||
data = strconv.AppendFloat(data, point.X-origin.X, 'g', -1, 64)
|
||||
data = append(data, ' ')
|
||||
data = strconv.AppendFloat(data, point.Y-origin.Y, 'g', -1, 64)
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -15,13 +15,23 @@
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf"
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// pdfDestinationFontBackend 禁止内容度量忽略实际绘制所需的缺失字体
|
||||
type pdfDestinationFontBackend struct {
|
||||
FontBackend
|
||||
}
|
||||
|
||||
// documentOpenActions 转换文档初始目标与打开动作,不执行脚本或打开资源
|
||||
// 入参: ctx 取消上下文
|
||||
// 返回: error 动作、编码或取消错误
|
||||
@@ -207,3 +217,197 @@ func (p *pdfImporter) annotationTriggerActions(ctx context.Context, annotation p
|
||||
}
|
||||
return converted, ctx.Err()
|
||||
}
|
||||
|
||||
// ResolveFont 沿用字体匹配,只在实际绘制缺少字体数据时中止度量
|
||||
// 入参: renderer 度量渲染器, id 字体标识, exact 是否禁止无关回退
|
||||
// 返回: ResolvedFont 字体资源, error 解析或缺失错误
|
||||
func (b pdfDestinationFontBackend) ResolveFont(renderer *Renderer, id string, exact bool) (ResolvedFont, error) {
|
||||
font, err := b.FontBackend.ResolveFont(renderer, id, exact)
|
||||
if err == nil && len(font.Data) == 0 {
|
||||
err = fmt.Errorf("PDF destination font %q unavailable", id)
|
||||
}
|
||||
return font, err
|
||||
}
|
||||
|
||||
// destinationBounds 度量目标页内容并缓存页面内范围,不包含注解或像素取整
|
||||
// 入参: page PDF目标页
|
||||
// 返回: Box OFD内容范围, error 度量错误
|
||||
func (p *pdfImporter) destinationBounds(page *pdfgo.Page) (Box, error) {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
if box, ok := p.destinationBoxes[page.Reference]; ok {
|
||||
return box, nil
|
||||
}
|
||||
if index, ok := p.pageIndexes[page.Reference]; ok {
|
||||
previous := p.page
|
||||
p.page = index
|
||||
defer func() { p.page = previous }()
|
||||
}
|
||||
matrix, width, height := codecpdf.PageMatrix(page)
|
||||
editor := NewEditor()
|
||||
editor.SetRenderBackends(p.editor.Backends())
|
||||
editor.SetFontDirs(p.editor.fontDirs...)
|
||||
editor.SetFontFS(p.editor.fontFS...)
|
||||
editor.fontSourcesCache = p.renderer.fontSourcesCache
|
||||
editor.validatedPaths = p.editor.pathValidationCache()
|
||||
if _, err := editor.AddPage(width, height); err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
local := pdfImporter{
|
||||
ctx: p.ctx, reader: p.reader, editor: editor, renderer: p.renderer,
|
||||
matrix: matrix, pageBox: page.CropBox, pageWidth: width, pageHeight: height,
|
||||
rasterDPI: p.rasterDPI, fontIDs: make(map[*pdfgo.Font]string),
|
||||
resolveFile: p.resolveFile, resolveReference: p.resolveReference,
|
||||
references: &codecpdf.ReferenceCache{},
|
||||
halftones: p.halftones, halftoneWarnings: make(map[*pdfgo.Halftone]bool),
|
||||
}
|
||||
defer local.references.Close()
|
||||
if err := local.compositePage(nil, func(visitor pdfgo.Visitor) error {
|
||||
return p.reader.WalkPage(p.ctx, page, visitor)
|
||||
}); err != nil {
|
||||
return Box{}, fmt.Errorf("PDF destination content: %w", err)
|
||||
}
|
||||
if err := local.commitObjects(); err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
reader, err := editor.resourceReader()
|
||||
if err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
defer reader.Close()
|
||||
renderer := p.renderer.childRenderer(reader)
|
||||
renderer.TransparentBackground = true
|
||||
renderer.RenderAnnotations = false
|
||||
if renderer.backends.Fonts != nil {
|
||||
renderer.backends.Fonts = pdfDestinationFontBackend{renderer.backends.Fonts}
|
||||
}
|
||||
scene, err := renderer.CompilePageContext(p.ctx, &editor.pages[0])
|
||||
if err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
if scene == nil {
|
||||
return Box{}, fmt.Errorf("PDF destination content scene unavailable")
|
||||
}
|
||||
geometry, err := renderer.Geometry()
|
||||
if err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
box, err := scene.BoundsContext(p.ctx, geometry)
|
||||
if err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
left, top := math.Max(0, box.X), math.Max(0, box.Y)
|
||||
right, bottom := math.Min(width, box.X+box.W), math.Min(height, box.Y+box.H)
|
||||
box = Box{X: left, Y: top, W: right - left, H: bottom - top}
|
||||
if !finite(left) || !finite(top) || !finite(right) || !finite(bottom) || box.W <= 0 || box.H <= 0 {
|
||||
return Box{}, fmt.Errorf("PDF destination content bounds empty or invalid")
|
||||
}
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
if p.destinationBoxes == nil {
|
||||
p.destinationBoxes = make(map[pdfgo.Reference]Box)
|
||||
}
|
||||
p.destinationBoxes[page.Reference] = box
|
||||
return box, nil
|
||||
}
|
||||
|
||||
// outlines 按先序批量转换目录,保留层级、展开状态和动作顺序
|
||||
// 入参: ctx 取消上下文
|
||||
// 返回: error 严格模式转换错误或取消错误
|
||||
func (p *pdfImporter) outlines(ctx context.Context) error {
|
||||
count := 0
|
||||
data, err := encodeOFDXMLContext(ctx, func(x *ofdXML) {
|
||||
x.root("Outlines", nil)
|
||||
depth := 0
|
||||
err := p.reader.WalkOutlines(ctx, func(level int, item pdfgo.OutlineItem) error {
|
||||
actions, err := p.outlineActions(ctx, item)
|
||||
if err != nil {
|
||||
if p.warning == nil || ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errPDFMediaRead) {
|
||||
return err
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outline %q actions not transferred to OFD: %v", item.Title, err)})
|
||||
actions = nil
|
||||
}
|
||||
for depth > level {
|
||||
x.end("OutlineElem")
|
||||
depth--
|
||||
}
|
||||
attrs := ofdAttrs{{Name: xml.Name{Local: "Title"}, Value: item.Title}}
|
||||
attrs.add("Expanded", strconv.FormatBool(item.Expanded))
|
||||
x.start("OutlineElem", attrs)
|
||||
x.actions(actions)
|
||||
depth++
|
||||
count++
|
||||
return x.err
|
||||
})
|
||||
if err != nil {
|
||||
x.err = err
|
||||
return
|
||||
}
|
||||
for depth > 0 {
|
||||
x.end("OutlineElem")
|
||||
depth--
|
||||
}
|
||||
x.end("Outlines")
|
||||
})
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if p.warning == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errPDFMediaRead) {
|
||||
return err
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outlines not transferred to OFD: %v", err)})
|
||||
return nil
|
||||
}
|
||||
if count > 0 {
|
||||
p.editor.outlines = bytes.TrimPrefix(data, []byte(xml.Header))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// outlineActions 转换目录目标及动作链,不为无目标节点补充跳转
|
||||
// 入参: ctx 取消上下文, item PDF目录节点
|
||||
// 返回: []Action OFD动作序列, error 动作或取消错误
|
||||
func (p *pdfImporter) outlineActions(ctx context.Context, item pdfgo.OutlineItem) ([]Action, error) {
|
||||
object, err := p.reader.Resolve(item.Action)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dest, err := p.reader.Resolve(item.Destination)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dest != nil {
|
||||
if object != nil {
|
||||
return nil, fmt.Errorf("PDF outline has both destination and action")
|
||||
}
|
||||
object = pdfgo.Dictionary{"S": pdfgo.Name("GoTo"), "D": dest}
|
||||
}
|
||||
currentPage := -1
|
||||
var actions []Action
|
||||
err = p.reader.WalkActions(ctx, object, func(info pdfgo.ActionInfo) error {
|
||||
converted, err := p.linkActions(pdfgo.Annotation{Dictionary: pdfgo.Dictionary{"A": info.Dictionary}}, p.warning == nil, ¤tPage)
|
||||
if err != nil {
|
||||
if errors.Is(err, errPDFMediaRead) {
|
||||
return err
|
||||
}
|
||||
var unsupported *pdfgo.UnsupportedError
|
||||
if p.warning != nil && (errors.As(err, &unsupported) || errors.Is(err, pdfgo.ErrInvalidDestination) || errors.Is(err, pdfgo.ErrInvalidAction)) {
|
||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outline action not transferred to OFD: %v", err)})
|
||||
currentPage = -1
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if converted != nil {
|
||||
actions = append(actions, converted...)
|
||||
} else {
|
||||
currentPage = -1
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return actions, err
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -21,6 +21,7 @@ import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"math"
|
||||
"reflect"
|
||||
"strconv"
|
||||
|
||||
basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom"
|
||||
@@ -34,6 +35,13 @@ type pdfPointBounds struct {
|
||||
found bool
|
||||
}
|
||||
|
||||
// pdfClipPath 保存导入器只读的裁剪几何,不缓存对象位移或文字裁剪
|
||||
type pdfClipPath struct {
|
||||
box Box
|
||||
data string
|
||||
path *[1]PathObject
|
||||
}
|
||||
|
||||
// pdfGeometryPath 转换PDF路径并衔接OFD几何类型
|
||||
// 入参: ctx 取消上下文, source PDF路径, matrix 坐标变换
|
||||
// 返回: GeometryPath 独立几何路径, error 指令、坐标或取消错误
|
||||
@@ -286,70 +294,6 @@ func (b *pdfPointBounds) box() Box {
|
||||
return Box{X: b.minX, Y: b.minY, W: math.Max(b.maxX-b.minX, 1e-6), H: math.Max(b.maxY-b.minY, 1e-6)}
|
||||
}
|
||||
|
||||
// pathBoundsContext 逐段计算变换后的控制点边界,不创建临时点集合
|
||||
// 入参: ctx 取消上下文, path PDF路径
|
||||
// 返回: Box 控制点边界,空路径返回零值, error 取消错误
|
||||
func (p *pdfImporter) pathBoundsContext(ctx context.Context, path pdfgo.Path) (Box, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
minX, minY, maxX, maxY := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1)
|
||||
found := false
|
||||
for _, segment := range path.Segments {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
for index, point := range segment.Points {
|
||||
if index&255 == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
}
|
||||
point = p.matrix.Apply(point)
|
||||
minX, minY = math.Min(minX, point.X), math.Min(minY, point.Y)
|
||||
maxX, maxY = math.Max(maxX, point.X), math.Max(maxY, point.Y)
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
if !found {
|
||||
return Box{}, nil
|
||||
}
|
||||
return Box{X: minX, Y: minY, W: math.Max(maxX-minX, 1e-6), H: math.Max(maxY-minY, 1e-6)}, nil
|
||||
}
|
||||
|
||||
// pathDataContext 转换路径并在分段处理时检查取消
|
||||
// 入参: ctx 取消上下文, path PDF路径, origin 对象边界
|
||||
// 返回: string OFD路径数据, error 取消错误
|
||||
func (p *pdfImporter) pathDataContext(ctx context.Context, path pdfgo.Path, origin Box) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
data := make([]byte, 0, len(path.Segments)*48)
|
||||
for index, segment := range path.Segments {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if index != 0 {
|
||||
data = append(data, ' ')
|
||||
}
|
||||
data = append(data, segment.Operator...)
|
||||
for _, point := range segment.Points {
|
||||
point = p.matrix.Apply(point)
|
||||
data = append(data, ' ')
|
||||
data = strconv.AppendFloat(data, point.X-origin.X, 'g', -1, 64)
|
||||
data = append(data, ' ')
|
||||
data = strconv.AppendFloat(data, point.Y-origin.Y, 'g', -1, 64)
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// path 保留每次路径绘制操作,重叠描边不合并为单次绘制
|
||||
// 入参: mark PDF路径绘制信息
|
||||
// 返回: error 错误信息
|
||||
@@ -383,7 +327,7 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error {
|
||||
}
|
||||
return p.gradientPath(mark)
|
||||
}
|
||||
box, err := p.pathBoundsContext(p.ctx, mark.Path)
|
||||
box, err := codecpdf.PathBounds(p.ctx, mark.Path, p.matrix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -447,7 +391,7 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := drawing.pathDataContext(p.ctx, mark.Path, origin)
|
||||
data, err := codecpdf.PathData(p.ctx, mark.Path, drawing.matrix, origin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -465,3 +409,232 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error {
|
||||
p.report.PathObjects++
|
||||
return nil
|
||||
}
|
||||
|
||||
// clipPath 复用页面内重复路径编码,按估算字节预算淘汰且不缓存错误
|
||||
// 入参: path 裁剪路径
|
||||
// 返回: pdfClipPath 变换后的边界及编码, error 取消错误
|
||||
func (p *pdfImporter) clipPath(path pdfgo.Path) (pdfClipPath, error) {
|
||||
key, err := codecpdf.ClipPathKey(p.ctx, path, p.matrix)
|
||||
if err != nil {
|
||||
return pdfClipPath{}, err
|
||||
}
|
||||
if p.clipPaths == nil {
|
||||
p.clipPaths = &renderCache[[32]byte, pdfClipPath]{limit: 8 << 20}
|
||||
}
|
||||
if cached, ok := p.clipPaths.get(key); ok {
|
||||
return cached, nil
|
||||
}
|
||||
box, err := codecpdf.PathBounds(p.ctx, path, p.matrix)
|
||||
if err != nil {
|
||||
return pdfClipPath{}, err
|
||||
}
|
||||
data, err := codecpdf.PathData(p.ctx, path, p.matrix, box)
|
||||
if err != nil {
|
||||
return pdfClipPath{}, err
|
||||
}
|
||||
rule := "NonZero"
|
||||
if path.EvenOdd {
|
||||
rule = "Even-Odd"
|
||||
}
|
||||
boundary := pdfBoundary(box)
|
||||
fill, stroke := true, false
|
||||
result := pdfClipPath{box: box, data: data, path: &[1]PathObject{{Boundary: boundary, AbbreviatedData: data, Rule: rule, Fill: &fill, Stroke: &stroke}}}
|
||||
p.clipPaths.put(key, result, len(data)+len(boundary)+512)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// sharedClipObjects 将连续重复的大裁剪区提取为复合对象,同步缓冲区及来源区段
|
||||
// 入参: objects 本库生成且尚未分配标识的对象
|
||||
// 返回: []GraphicObject 提取后的对象, error 资源创建或取消错误
|
||||
func (p *pdfImporter) sharedClipObjects(objects []GraphicObject) ([]GraphicObject, error) {
|
||||
var output []GraphicObject
|
||||
var mapped pdfObjectBuffer
|
||||
for start := 0; start < len(objects); {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clips := pdfPageClip(objects[start])
|
||||
end := start + 1
|
||||
if clips != nil {
|
||||
for end < len(objects) && p.objects.origin(start).equal(p.objects.origin(end)) && pdfMatchesPageClip(objects[end], clips) {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
end++
|
||||
}
|
||||
}
|
||||
if end-start < 2 {
|
||||
if output != nil {
|
||||
mapped.recordOrigin(len(output), p.objects.origin(start))
|
||||
output = append(output, objects[start])
|
||||
}
|
||||
start = end
|
||||
continue
|
||||
}
|
||||
if output == nil {
|
||||
output = append(make([]GraphicObject, 0, len(objects)), objects[:start]...)
|
||||
if p.objects != nil {
|
||||
for _, run := range p.objects.sequences {
|
||||
if run.start >= start {
|
||||
break
|
||||
}
|
||||
mapped.sequences = append(mapped.sequences, run)
|
||||
}
|
||||
}
|
||||
}
|
||||
members := objects[start:end:end]
|
||||
for index := range members {
|
||||
*editorObjectClips(&members[index]) = nil
|
||||
}
|
||||
id, err := p.objectValidation().addOwnedVector(p.ctx, members, p.pageWidth, p.pageHeight)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mapped.recordOrigin(len(output), p.objects.origin(start))
|
||||
output = append(output, GraphicObject{Type: "CompositeObject", CompositeGraphicUnit: CompositeGraphicUnit{
|
||||
Boundary: pdfBoundary(Box{W: p.pageWidth, H: p.pageHeight}), ResourceID: id, Clips: clips,
|
||||
}})
|
||||
start = end
|
||||
}
|
||||
if output == nil {
|
||||
return objects, nil
|
||||
}
|
||||
if p.objects != nil {
|
||||
mapped.parts = [][]GraphicObject{output}
|
||||
mapped.size = len(output)
|
||||
*p.objects = mapped
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// pdfPageClip 将导入裁剪区还原到页面坐标,仅提取包含大量重复路径数据的裁剪
|
||||
// 入参: object 导入对象
|
||||
// 返回: *Clips 页面坐标裁剪,不满足提取条件时为空
|
||||
func pdfPageClip(object GraphicObject) *Clips {
|
||||
if object.Type != "TextObject" && object.Type != "PathObject" && object.Type != "ImageObject" {
|
||||
return nil
|
||||
}
|
||||
clips := *editorObjectClips(&object)
|
||||
if clips == nil || clips.usesObjectMatrix() || len(clips.Clip) == 0 {
|
||||
return nil
|
||||
}
|
||||
boundary, _ := editorGeometry(object)
|
||||
box, err := ParseBox(boundary)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
translation := ""
|
||||
if box.X != 0 || box.Y != 0 {
|
||||
translation = pdfNumbers(1, 0, 0, 1, -box.X, -box.Y)
|
||||
}
|
||||
bytes := 0
|
||||
result := &Clips{Clip: make([]Clip, len(clips.Clip))}
|
||||
for index, clip := range clips.Clip {
|
||||
if len(clip.Area) != 1 || clip.Area[0].CTM != translation {
|
||||
return nil
|
||||
}
|
||||
area := clip.Area[0]
|
||||
area.CTM = ""
|
||||
result.Clip[index].Area = []ClipArea{area}
|
||||
for _, path := range area.Path {
|
||||
bytes += len(path.AbbreviatedData)
|
||||
}
|
||||
}
|
||||
if bytes < 2048 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// pdfMatchesPageClip 比较页面坐标裁剪,不将相同外接矩形视为相同路径
|
||||
// 入参: object 导入对象, pageClip 已还原的裁剪区
|
||||
// 返回: bool 是否可共享裁剪区
|
||||
func pdfMatchesPageClip(object GraphicObject, pageClip *Clips) bool {
|
||||
if object.Type != "TextObject" && object.Type != "PathObject" && object.Type != "ImageObject" {
|
||||
return false
|
||||
}
|
||||
clips := *editorObjectClips(&object)
|
||||
if clips == nil || clips.usesObjectMatrix() || len(clips.Clip) != len(pageClip.Clip) {
|
||||
return false
|
||||
}
|
||||
boundary, _ := editorGeometry(object)
|
||||
box, err := ParseBox(boundary)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
translation := ""
|
||||
if box.X != 0 || box.Y != 0 {
|
||||
translation = pdfNumbers(1, 0, 0, 1, -box.X, -box.Y)
|
||||
}
|
||||
for index, clip := range clips.Clip {
|
||||
if len(clip.Area) != 1 || clip.Area[0].CTM != translation {
|
||||
return false
|
||||
}
|
||||
area, expected := clip.Area[0], pageClip.Clip[index].Area[0]
|
||||
if area.DrawParam != expected.DrawParam || !reflect.DeepEqual(area.Path, expected.Path) || !reflect.DeepEqual(area.Text, expected.Text) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// clips 省略覆盖对象边界的矩形裁剪,复用其余几何并保留位移及路径交集
|
||||
// 入参: paths 裁剪路径, origin 对象边界
|
||||
// 返回: *Clips OFD裁剪区域,无裁剪时为空, error 未解析的裁剪字形
|
||||
func (p *pdfImporter) clips(paths []pdfgo.Path, origin Box) (*Clips, error) {
|
||||
if len(paths) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var clips *Clips
|
||||
var areas []ClipArea
|
||||
translation := ""
|
||||
for _, path := range paths {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if codecpdf.ClipContainsBoundary(path, p.matrix, origin) {
|
||||
continue
|
||||
}
|
||||
if clips == nil {
|
||||
clips = &Clips{Clip: make([]Clip, 0, len(paths))}
|
||||
areas = make([]ClipArea, len(paths))
|
||||
if origin.X != 0 || origin.Y != 0 {
|
||||
translation = pdfNumbers(1, 0, 0, 1, -origin.X, -origin.Y)
|
||||
}
|
||||
}
|
||||
index := len(clips.Clip)
|
||||
area := &areas[index]
|
||||
area.CTM = translation
|
||||
if len(path.Segments) != 0 {
|
||||
encoded, err := p.clipPath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
area.Path = encoded.path[:1:1]
|
||||
}
|
||||
for _, mark := range path.Text {
|
||||
objects, ok := p.clipTexts[mark]
|
||||
if !ok {
|
||||
count := p.objects.len()
|
||||
textCount := p.report.TextObjects
|
||||
if err := p.text(pdfgo.TextMark{Font: mark.Font, Glyphs: mark.Glyphs, Positions: mark.Positions, Matrix: mark.Matrix, Size: mark.Size, HorizontalScale: mark.HorizontalScale, Mode: 7, Clip: mark, Style: pdfgo.Style{Fill: pdfgo.Paint{Alpha: 1}}}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.objects.truncate(count)
|
||||
p.report.TextObjects = textCount
|
||||
objects = p.clipTexts[mark]
|
||||
}
|
||||
if len(path.Text) == 1 {
|
||||
area.Text = objects[:len(objects):len(objects)]
|
||||
} else {
|
||||
area.Text = append(area.Text, objects...)
|
||||
}
|
||||
}
|
||||
if len(area.Path)+len(area.Text) == 0 {
|
||||
fill, stroke := true, false
|
||||
area.Path = []PathObject{{Boundary: "0 0 1 1", AbbreviatedData: "M 0 0 C", Fill: &fill, Stroke: &stroke}}
|
||||
}
|
||||
clips.Clip = append(clips.Clip, Clip{Area: areas[index : index+1 : index+1]})
|
||||
}
|
||||
return clips, nil
|
||||
}
|
||||
+4
-4
@@ -155,7 +155,7 @@ func (p *pdfImporter) groupPaths(mark pdfgo.GroupMark, walk func(pdfgo.Visitor)
|
||||
if path.Style.SoftMask != nil || path.Style.BlendMode != "" && path.Style.BlendMode != "Normal" && path.Style.BlendMode != "Compatible" {
|
||||
return &pdfgo.UnsupportedError{Feature: "masked or blended transparent group content"}
|
||||
}
|
||||
data, err := p.pathDataContext(p.ctx, path.Path, Box{})
|
||||
data, err := codecpdf.PathData(p.ctx, path.Path, p.matrix, Box{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -205,7 +205,7 @@ func (p *pdfImporter) groupPaths(mark pdfgo.GroupMark, walk func(pdfgo.Visitor)
|
||||
return fmt.Errorf("invalid PDF stroke matrix")
|
||||
}
|
||||
local := pdfImporter{matrix: inverse}
|
||||
data, err = local.pathDataContext(p.ctx, path.Path, Box{})
|
||||
data, err = codecpdf.PathData(p.ctx, path.Path, local.matrix, Box{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -677,7 +677,7 @@ func (p *pdfImporter) maskClip(mask *pdfgo.SoftMask) (pdfgo.Path, error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := identity.pathDataContext(p.ctx, mark.Path, Box{})
|
||||
data, err := codecpdf.PathData(p.ctx, mark.Path, identity.matrix, Box{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -693,7 +693,7 @@ func (p *pdfImporter) maskClip(mask *pdfgo.SoftMask) (pdfgo.Path, error) {
|
||||
if len(clip.Text) != 0 {
|
||||
return &pdfgo.UnsupportedError{Feature: "text-clipped binary mask"}
|
||||
}
|
||||
data, err := identity.pathDataContext(p.ctx, clip, Box{})
|
||||
data, err := codecpdf.PathData(p.ctx, clip, identity.matrix, Box{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -24,6 +25,13 @@ import (
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// pdfEmbeddedFile 保存目标路径的容器与原始文件,阅读器按需打开
|
||||
type pdfEmbeddedFile struct {
|
||||
reader *pdfgo.Reader
|
||||
file *pdfgo.FileSpecification
|
||||
data []byte
|
||||
}
|
||||
|
||||
// annotations 将链接、静态注解和表单外观按页面顺序转换为OFD对象
|
||||
// 入参: ctx 取消上下文, page PDF页面, strict 严格检查开关
|
||||
// 返回: error 错误信息
|
||||
@@ -575,3 +583,182 @@ func (p *pdfImporter) staticLinkAction(object pdfgo.Object, kind pdfgo.Name, str
|
||||
p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF " + string(kind) + " action not transferred to OFD; static appearance retained"})
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// launchLinkAction 保留文件打开目标,不执行应用程序或猜测平台启动参数
|
||||
// 入参: object 启动动作, strict 是否禁止启动语义损失
|
||||
// 返回: *Action 附件动作, error 解析、取消或无法表达的语义
|
||||
func (p *pdfImporter) launchLinkAction(object pdfgo.Object, strict bool) (*Action, error) {
|
||||
action, err := p.reader.ReadLaunchAction(p.ctx, object)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if action.File == nil || action.Windows != nil || action.Mac != nil || action.Unix != nil {
|
||||
if strict {
|
||||
return nil, &pdfgo.UnsupportedError{Feature: "platform launch action conversion"}
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Message: "PDF platform launch parameters not transferred to OFD; appearance retained"})
|
||||
return nil, nil
|
||||
}
|
||||
if strict && action.NewWindow == nil {
|
||||
return nil, &pdfgo.UnsupportedError{Feature: "launch reader window preference conversion"}
|
||||
}
|
||||
data, available, err := p.mediaData(p.ctx, *action.File)
|
||||
if err != nil || !available {
|
||||
return nil, err
|
||||
}
|
||||
pdfTarget := bytes.HasPrefix(bytes.TrimSpace(data[:min(len(data), 1024)]), []byte("%PDF-"))
|
||||
if !pdfTarget && strict {
|
||||
return nil, &pdfgo.UnsupportedError{Feature: "application or non-PDF launch action conversion"}
|
||||
}
|
||||
id, err := p.attachmentFile(action.File.Name, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
window := action.NewWindow
|
||||
if !pdfTarget {
|
||||
window = nil
|
||||
p.warning(pdfgo.Diagnostic{Message: "PDF launch target retained as attachment; application execution not transferred to OFD"})
|
||||
} else if window == nil {
|
||||
p.warning(pdfgo.Diagnostic{Message: "PDF launch target retained as attachment; reader window preference not transferred to OFD"})
|
||||
}
|
||||
return &Action{Event: "CLICK", GotoA: &GotoA{AttachID: id, NewWindow: window}}, nil
|
||||
}
|
||||
|
||||
// remoteLinkAction 保留调用方提供的远程文件,不生成OFD无法表达的文件内目标
|
||||
// 入参: object 远程跳转动作, strict 是否禁止目标语义损失
|
||||
// 返回: *Action 附件动作, error 结构、取消或转换错误
|
||||
func (p *pdfImporter) remoteLinkAction(object pdfgo.Object, strict bool) (*Action, error) {
|
||||
action, err := p.reader.ReadExternalGoToAction(p.ctx, object)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strict {
|
||||
return nil, &pdfgo.UnsupportedError{Feature: "remote document destination conversion"}
|
||||
}
|
||||
if p.resolveFile == nil {
|
||||
p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF remote link file unavailable: " + action.File.Name + "; appearance retained, remote destination not transferred to OFD"})
|
||||
return nil, nil
|
||||
}
|
||||
data, err := p.reader.ReadFileData(p.ctx, *action.File, p.resolveFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := p.attachmentFile(action.File.Name, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
message := "PDF remote link target retained as attachment; destination inside attached PDF not transferred to OFD"
|
||||
if action.NewWindow == nil {
|
||||
message += "; reader window preference not transferred"
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: message})
|
||||
return &Action{Event: "CLICK", GotoA: &GotoA{AttachID: id, NewWindow: action.NewWindow}}, nil
|
||||
}
|
||||
|
||||
// embeddedLinkAction 按目标路径定位附件,回到源文档时保留页内目标
|
||||
// 入参: object 嵌入文件动作, strict 是否禁止附件内目标语义损失
|
||||
// 返回: *Action 附件动作, pdfgo.Object 源文档目标, error 目标或转换错误
|
||||
func (p *pdfImporter) embeddedLinkAction(object pdfgo.Object, strict bool) (*Action, pdfgo.Object, error) {
|
||||
action, err := p.reader.ReadExternalGoToAction(p.ctx, object)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
stack := []pdfEmbeddedFile{{reader: p.reader}}
|
||||
if action.File != nil {
|
||||
data, available, err := p.mediaData(p.ctx, *action.File)
|
||||
if err != nil || !available {
|
||||
return nil, nil, err
|
||||
}
|
||||
stack[0] = pdfEmbeddedFile{file: action.File, data: data}
|
||||
}
|
||||
var opened []*pdfgo.Reader
|
||||
defer func() {
|
||||
for _, reader := range opened {
|
||||
reader.Close()
|
||||
}
|
||||
}()
|
||||
open := func(file *pdfEmbeddedFile) error {
|
||||
if file.reader != nil {
|
||||
return nil
|
||||
}
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if file.data == nil {
|
||||
var err error
|
||||
file.data, err = p.reader.ReadFileData(p.ctx, *file.file, p.resolveFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
reader, err := pdfgo.NewReaderWithOptions(bytes.NewReader(file.data), int64(len(file.data)), pdfgo.ReaderOptions{Warning: p.warning})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file.reader = reader
|
||||
opened = append(opened, reader)
|
||||
return p.ctx.Err()
|
||||
}
|
||||
for _, target := range action.Target {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if target.Relation == "P" {
|
||||
if len(stack) == 1 {
|
||||
if strict {
|
||||
return nil, nil, &pdfgo.UnsupportedError{Feature: "embedded link parent document unavailable"}
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF embedded link parent document unavailable; link omitted"})
|
||||
return nil, nil, nil
|
||||
}
|
||||
stack = stack[:len(stack)-1]
|
||||
continue
|
||||
}
|
||||
current := &stack[len(stack)-1]
|
||||
if err := open(current); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
file, err := current.reader.ReadEmbeddedTargetFile(p.ctx, target)
|
||||
if err != nil {
|
||||
if !strict && errors.Is(err, pdfgo.ErrDestinationNotFound) {
|
||||
p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: fmt.Sprintf("%v; link omitted", err)})
|
||||
return nil, nil, nil
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
stack = append(stack, pdfEmbeddedFile{file: &file})
|
||||
}
|
||||
current := &stack[len(stack)-1]
|
||||
if current.file == nil {
|
||||
destination, err := current.reader.ResolveRemoteDestination(p.ctx, action.Destination)
|
||||
if err != nil {
|
||||
if !strict && errors.Is(err, pdfgo.ErrDestinationNotFound) {
|
||||
p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: fmt.Sprintf("%v; link omitted", err)})
|
||||
return nil, nil, nil
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
if action.NewWindow != nil && *action.NewWindow {
|
||||
if strict {
|
||||
return nil, nil, &pdfgo.UnsupportedError{Feature: "embedded link to source document in new window"}
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF embedded link new window not transferred to OFD; source document destination retained"})
|
||||
}
|
||||
return nil, append(pdfgo.Array{destination.Page, destination.Mode}, destination.Parameters...), nil
|
||||
}
|
||||
if strict {
|
||||
return nil, nil, &pdfgo.UnsupportedError{Feature: "destination inside attached PDF"}
|
||||
}
|
||||
if current.data == nil {
|
||||
current.data, err = p.reader.ReadFileData(p.ctx, *current.file, p.resolveFile)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
id, err := p.attachmentFile(current.file.Name, current.data)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF embedded link target retained as attachment; destination inside attached PDF not transferred to OFD"})
|
||||
return &Action{Event: "CLICK", GotoA: &GotoA{AttachID: id, NewWindow: action.NewWindow}}, nil, nil
|
||||
}
|
||||
@@ -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
@@ -16,6 +16,7 @@ package ofdgo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
@@ -27,9 +28,6 @@ import (
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// errPDFMediaRead 标识媒体读取失败,避免作为可恢复的导航问题忽略
|
||||
var errPDFMediaRead = errors.New("PDF media read failed")
|
||||
|
||||
// pdfMediaFileKey 区分媒体文件及注册用途,避免重复解码或调用文件解析器
|
||||
type pdfMediaFileKey struct {
|
||||
Stream *pdfgo.Stream
|
||||
@@ -55,6 +53,15 @@ type pdfMediaInstanceKey struct {
|
||||
Resource string
|
||||
}
|
||||
|
||||
// pdfAttachmentKey 按显示名与原始数据复用PDF附件,不合并名称不同的附件
|
||||
type pdfAttachmentKey struct {
|
||||
name string
|
||||
digest [32]byte
|
||||
}
|
||||
|
||||
// errPDFMediaRead 标识媒体读取失败,避免作为可恢复的导航问题忽略
|
||||
var errPDFMediaRead = errors.New("PDF media read failed")
|
||||
|
||||
// mediaInstance 为播放目标分配独立标识,同一目标的动作继续共用控制状态
|
||||
// 入参: id 已注册的媒体资源, target 目标注解, page 所属页面索引
|
||||
// 返回: string 独立资源标识, error 资源复制或取消错误
|
||||
@@ -313,3 +320,258 @@ func (p *pdfImporter) mediaData(ctx context.Context, file pdfgo.FileSpecificatio
|
||||
}
|
||||
return data, true, nil
|
||||
}
|
||||
|
||||
// movieResource 保存视频原始内容,按用途复用媒体或附件资源
|
||||
// 入参: ctx 取消上下文, file 文件说明, attachment 是否作为附件保存
|
||||
// 返回: string 媒体或附件标识,缺少外部资源时为空, error 读取、注册或取消错误
|
||||
func (p *pdfImporter) movieResource(ctx context.Context, file pdfgo.FileSpecification, attachment bool) (string, error) {
|
||||
return p.mediaResource(ctx, file, "Video", codecpdf.MediaFormat(file), attachment)
|
||||
}
|
||||
|
||||
// movieAnnotation 保留视频外观及可表示的播放动作,无法等价转换时按策略处理
|
||||
// 入参: ctx 取消上下文, page PDF页面, annotation 视频注解, strict 是否禁止语义损失
|
||||
// 返回: error 媒体、外观、播放参数或取消错误
|
||||
func (p *pdfImporter) movieAnnotation(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation, strict bool) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
movie, err := p.reader.ReadMovie(annotation.Dictionary["Movie"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
activation, err := p.reader.Resolve(annotation.Dictionary["A"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if activation == pdfgo.Boolean(false) {
|
||||
return p.appearanceAnnotation(ctx, page, annotation)
|
||||
}
|
||||
if activation == pdfgo.Boolean(true) {
|
||||
activation = nil
|
||||
}
|
||||
parameters, err := p.reader.ReadMovieActivation(ctx, activation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !pdfMoviePlaybackSupported(movie, parameters) {
|
||||
if err := p.moviePlaybackLoss(ctx, movie.File, strict); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.appearanceAnnotation(ctx, page, annotation)
|
||||
}
|
||||
if parameters.FloatingScale != nil {
|
||||
if err := p.movieActionLoss("movie playback window", strict, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
id, err := p.movieResource(ctx, movie.File, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if id == "" {
|
||||
return p.appearanceAnnotation(ctx, page, annotation)
|
||||
}
|
||||
id, err = p.mediaInstance(id, annotation, p.page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.appearanceAnnotation(ctx, page, annotation, Action{Event: "CLICK", Movie: &Movie{ResourceID: id, Operator: "Play"}})
|
||||
}
|
||||
|
||||
// pdfMoviePlaybackSupported 判断OFD视频动作能否保持播放内容、声音及控制方式
|
||||
// 入参: movie 视频信息, parameters 播放参数
|
||||
// 返回: bool 是否可直接转换,不含播放窗口差异
|
||||
func pdfMoviePlaybackSupported(movie pdfgo.Movie, parameters pdfgo.MovieActivation) bool {
|
||||
return (parameters.Start == nil || parameters.Start.Value == 0) && parameters.Duration == nil && parameters.Rate == 1 && parameters.Volume == 1 && !parameters.ShowControls && !parameters.Synchronous && parameters.Mode == "Once" && movie.Rotate == 0
|
||||
}
|
||||
|
||||
// movieLinkAction 转换当前页视频操作,不将指定播放区间或速度改为完整默认播放
|
||||
// 入参: object PDF视频动作, source 触发注解, strict 是否禁止语义损失, currentPage 动作链当前页
|
||||
// 返回: *Action OFD视频动作,不能安全转换时为空, error 参数、资源或严格模式错误
|
||||
func (p *pdfImporter) movieLinkAction(object pdfgo.Object, source pdfgo.Annotation, strict bool, currentPage int) (*Action, error) {
|
||||
if currentPage < 0 || currentPage >= len(p.pageOrder) {
|
||||
if _, err := p.reader.ReadMovieAction(p.ctx, object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, p.movieActionLoss("movie target without a fixed current page", strict, false)
|
||||
}
|
||||
playback, err := p.reader.ReadMoviePlayback(p.ctx, p.pageOrder[currentPage], object)
|
||||
if err != nil {
|
||||
if !strict && errors.Is(err, pdfgo.ErrInvalidAction) {
|
||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("invalid PDF movie action ignored: %v", err)})
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if playback.Operation == "Play" {
|
||||
parameters := playback.Activation
|
||||
if !pdfMoviePlaybackSupported(playback.Movie, parameters) {
|
||||
return nil, p.moviePlaybackLoss(p.ctx, playback.Movie.File, strict)
|
||||
}
|
||||
if parameters.FloatingScale != nil {
|
||||
if err := p.movieActionLoss("movie playback window", strict, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
id, err := p.movieResource(p.ctx, playback.Movie.File, false)
|
||||
if err != nil || id == "" {
|
||||
return nil, err
|
||||
}
|
||||
id, err = p.mediaInstance(id, playback.Annotation, currentPage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
action := &Action{Event: "CLICK", Movie: &Movie{ResourceID: id, Operator: string(playback.Operation)}}
|
||||
if playback.Operation == "Play" && playback.Activation.FloatingScale == nil {
|
||||
if err := p.moviePlaybackBounds(action.Movie, source, playback.Annotation, currentPage, strict); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return action, nil
|
||||
}
|
||||
|
||||
// moviePlaybackBounds 保存同页嵌入视频的播放区域,不改变触发注解的点击区域
|
||||
// 入参: movie OFD视频动作, source 触发注解, target 播放注解, currentPage 当前页, strict 是否禁止语义损失
|
||||
// 返回: error 播放区域、标志或严格模式错误
|
||||
func (p *pdfImporter) moviePlaybackBounds(movie *Movie, source, target pdfgo.Annotation, currentPage int, strict bool) error {
|
||||
if source.Subtype == "" || currentPage != p.page {
|
||||
return p.movieActionLoss("movie playback window", strict, true)
|
||||
}
|
||||
if source.Rect == target.Rect {
|
||||
return nil
|
||||
}
|
||||
flags, err := p.reader.ReadAnnotationFlags(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if flags&(8|16) != 0 {
|
||||
return p.movieActionLoss("movie playback window scaling", strict, true)
|
||||
}
|
||||
box := pdfBounds([]pdfgo.Point{
|
||||
p.matrix.Apply(pdfgo.Point{X: target.Rect.XMin, Y: target.Rect.YMin}),
|
||||
p.matrix.Apply(pdfgo.Point{X: target.Rect.XMax, Y: target.Rect.YMin}),
|
||||
p.matrix.Apply(pdfgo.Point{X: target.Rect.XMax, Y: target.Rect.YMax}),
|
||||
p.matrix.Apply(pdfgo.Point{X: target.Rect.XMin, Y: target.Rect.YMax}),
|
||||
})
|
||||
if box.W <= 0 || box.H <= 0 {
|
||||
return p.movieActionLoss("empty movie playback window", strict, true)
|
||||
}
|
||||
if p.movieBounds == nil {
|
||||
p.movieBounds = make(map[*Movie]Box)
|
||||
}
|
||||
p.movieBounds[movie] = box
|
||||
return nil
|
||||
}
|
||||
|
||||
// annotationMovieBounds 校验同一动作链的播放区域,保持单一图元上的动作顺序
|
||||
// 入参: actions 注解动作, source 触发区域, flags 触发注解标志
|
||||
// 返回: Box 播放区域, error 无法等价转换的窗口要求
|
||||
func (p *pdfImporter) annotationMovieBounds(actions []Action, source Box, flags int64) (Box, error) {
|
||||
playback, found := source, false
|
||||
for _, action := range actions {
|
||||
if action.Movie == nil || action.Movie.Operator != "" && action.Movie.Operator != "Play" {
|
||||
continue
|
||||
}
|
||||
box, ok := p.movieBounds[action.Movie]
|
||||
if !ok {
|
||||
box = source
|
||||
}
|
||||
if found && playback != box || box != source && flags&(8|16) != 0 {
|
||||
return source, p.movieActionLoss("multiple or fixed-scale movie playback windows", p.warning == nil, true)
|
||||
}
|
||||
playback, found = box, true
|
||||
}
|
||||
return playback, nil
|
||||
}
|
||||
|
||||
// moviePlaybackLoss 保留不可等价转换的视频文件,不生成改变播放内容的动作
|
||||
// 入参: ctx 取消上下文, file 视频文件说明, strict 是否禁止语义损失
|
||||
// 返回: error 严格模式、资源读取或取消错误
|
||||
func (p *pdfImporter) moviePlaybackLoss(ctx context.Context, file pdfgo.FileSpecification, strict bool) error {
|
||||
if strict {
|
||||
return &pdfgo.UnsupportedError{Feature: "movie playback parameters conversion"}
|
||||
}
|
||||
id, err := p.movieResource(ctx, file, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message := "PDF movie playback parameters not transferred to OFD; action omitted"
|
||||
if id != "" {
|
||||
message += "; original media retained as attachment"
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: message})
|
||||
return nil
|
||||
}
|
||||
|
||||
// movieActionLoss 按策略报告OFD无法表达的视频参数,不自行添加扩展属性
|
||||
// 入参: feature 差异内容, strict 严格模式, retained 是否保留视频操作
|
||||
// 返回: error 严格模式下的不可等价转换错误
|
||||
func (p *pdfImporter) movieActionLoss(feature string, strict, retained bool) error {
|
||||
if strict {
|
||||
return &pdfgo.UnsupportedError{Feature: feature + " conversion"}
|
||||
}
|
||||
suffix := "; action omitted"
|
||||
if retained {
|
||||
suffix = "; video operation retained"
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Message: "PDF " + feature + " not transferred to OFD" + suffix})
|
||||
return nil
|
||||
}
|
||||
|
||||
// documentAttachments 导入文档名称树中的附件,与注解及动作引用共用附件资源
|
||||
// 入参: ctx 取消上下文
|
||||
// 返回: error 名称树、附件读取或写入错误
|
||||
func (p *pdfImporter) documentAttachments(ctx context.Context) error {
|
||||
return p.reader.WalkEmbeddedFiles(ctx, func(_ string, file pdfgo.FileSpecification) error {
|
||||
if file.Embedded == nil {
|
||||
if p.warning == nil {
|
||||
return fmt.Errorf("PDF embedded file has no embedded data")
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Message: "PDF embedded file without embedded data ignored"})
|
||||
return nil
|
||||
}
|
||||
data, err := file.Embedded.DecodeContext(ctx)
|
||||
if err != nil {
|
||||
var unsupported *pdfgo.UnsupportedError
|
||||
if p.warning != nil && errors.As(err, &unsupported) {
|
||||
p.warning(pdfgo.Diagnostic{Message: "PDF document attachment not transferred to OFD: " + err.Error()})
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
_, err = p.attachmentFile(file.Name, data)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// attachmentFile 按原名及数据复用附件,兼容末尾NUL,缺名使用固定名称
|
||||
// 入参: name 附件显示名, data 原始文件数据
|
||||
// 返回: string 附件标识, error 附件写入错误
|
||||
func (p *pdfImporter) attachmentFile(name string, data []byte) (string, error) {
|
||||
if name == "" {
|
||||
name = "Attachment"
|
||||
}
|
||||
key := pdfAttachmentKey{name: name, digest: sha256.Sum256(data)}
|
||||
if id, ok := p.attachmentIDs[key]; ok {
|
||||
return id, nil
|
||||
}
|
||||
if p.warning != nil {
|
||||
name = strings.TrimRight(name, "\x00")
|
||||
if name == "" {
|
||||
name = "Attachment"
|
||||
}
|
||||
}
|
||||
id, err := p.editor.AddAttachment(name, data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if p.attachmentIDs == nil {
|
||||
p.attachmentIDs = make(map[pdfAttachmentKey]string)
|
||||
}
|
||||
p.attachmentIDs[key] = id
|
||||
if name != key.name {
|
||||
p.warning(pdfgo.Diagnostic{Message: "PDF attachment name trailing NUL removed; file data retained"})
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf"
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// pdfDestinationFontBackend 禁止内容度量忽略实际绘制所需的缺失字体
|
||||
type pdfDestinationFontBackend struct {
|
||||
FontBackend
|
||||
}
|
||||
|
||||
// ResolveFont 沿用字体匹配,只在实际绘制缺少字体数据时中止度量
|
||||
// 入参: renderer 度量渲染器, id 字体标识, exact 是否禁止无关回退
|
||||
// 返回: ResolvedFont 字体资源, error 解析或缺失错误
|
||||
func (b pdfDestinationFontBackend) ResolveFont(renderer *Renderer, id string, exact bool) (ResolvedFont, error) {
|
||||
font, err := b.FontBackend.ResolveFont(renderer, id, exact)
|
||||
if err == nil && len(font.Data) == 0 {
|
||||
err = fmt.Errorf("PDF destination font %q unavailable", id)
|
||||
}
|
||||
return font, err
|
||||
}
|
||||
|
||||
// destinationBounds 度量目标页内容并缓存页面内范围,不包含注解或像素取整
|
||||
// 入参: page PDF目标页
|
||||
// 返回: Box OFD内容范围, error 度量错误
|
||||
func (p *pdfImporter) destinationBounds(page *pdfgo.Page) (Box, error) {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
if box, ok := p.destinationBoxes[page.Reference]; ok {
|
||||
return box, nil
|
||||
}
|
||||
if index, ok := p.pageIndexes[page.Reference]; ok {
|
||||
previous := p.page
|
||||
p.page = index
|
||||
defer func() { p.page = previous }()
|
||||
}
|
||||
matrix, width, height := codecpdf.PageMatrix(page)
|
||||
editor := NewEditor()
|
||||
editor.SetRenderBackends(p.editor.Backends())
|
||||
editor.SetFontDirs(p.editor.fontDirs...)
|
||||
editor.SetFontFS(p.editor.fontFS...)
|
||||
editor.fontSourcesCache = p.renderer.fontSourcesCache
|
||||
editor.validatedPaths = p.editor.pathValidationCache()
|
||||
if _, err := editor.AddPage(width, height); err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
local := pdfImporter{
|
||||
ctx: p.ctx, reader: p.reader, editor: editor, renderer: p.renderer,
|
||||
matrix: matrix, pageBox: page.CropBox, pageWidth: width, pageHeight: height,
|
||||
rasterDPI: p.rasterDPI, fontIDs: make(map[*pdfgo.Font]string),
|
||||
resolveFile: p.resolveFile, resolveReference: p.resolveReference,
|
||||
references: &codecpdf.ReferenceCache{},
|
||||
halftones: p.halftones, halftoneWarnings: make(map[*pdfgo.Halftone]bool),
|
||||
}
|
||||
defer local.references.Close()
|
||||
if err := local.compositePage(nil, func(visitor pdfgo.Visitor) error {
|
||||
return p.reader.WalkPage(p.ctx, page, visitor)
|
||||
}); err != nil {
|
||||
return Box{}, fmt.Errorf("PDF destination content: %w", err)
|
||||
}
|
||||
if err := local.commitObjects(); err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
reader, err := editor.resourceReader()
|
||||
if err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
defer reader.Close()
|
||||
renderer := p.renderer.childRenderer(reader)
|
||||
renderer.TransparentBackground = true
|
||||
renderer.RenderAnnotations = false
|
||||
if renderer.backends.Fonts != nil {
|
||||
renderer.backends.Fonts = pdfDestinationFontBackend{renderer.backends.Fonts}
|
||||
}
|
||||
scene, err := renderer.CompilePageContext(p.ctx, &editor.pages[0])
|
||||
if err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
if scene == nil {
|
||||
return Box{}, fmt.Errorf("PDF destination content scene unavailable")
|
||||
}
|
||||
geometry, err := renderer.Geometry()
|
||||
if err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
box, err := scene.BoundsContext(p.ctx, geometry)
|
||||
if err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
left, top := math.Max(0, box.X), math.Max(0, box.Y)
|
||||
right, bottom := math.Min(width, box.X+box.W), math.Min(height, box.Y+box.H)
|
||||
box = Box{X: left, Y: top, W: right - left, H: bottom - top}
|
||||
if !finite(left) || !finite(top) || !finite(right) || !finite(bottom) || box.W <= 0 || box.H <= 0 {
|
||||
return Box{}, fmt.Errorf("PDF destination content bounds empty or invalid")
|
||||
}
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
if p.destinationBoxes == nil {
|
||||
p.destinationBoxes = make(map[pdfgo.Reference]Box)
|
||||
}
|
||||
p.destinationBoxes[page.Reference] = box
|
||||
return box, nil
|
||||
}
|
||||
|
||||
// outlines 按先序批量转换目录,保留层级、展开状态和动作顺序
|
||||
// 入参: ctx 取消上下文
|
||||
// 返回: error 严格模式转换错误或取消错误
|
||||
func (p *pdfImporter) outlines(ctx context.Context) error {
|
||||
count := 0
|
||||
data, err := encodeOFDXMLContext(ctx, func(x *ofdXML) {
|
||||
x.root("Outlines", nil)
|
||||
depth := 0
|
||||
err := p.reader.WalkOutlines(ctx, func(level int, item pdfgo.OutlineItem) error {
|
||||
actions, err := p.outlineActions(ctx, item)
|
||||
if err != nil {
|
||||
if p.warning == nil || ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errPDFMediaRead) {
|
||||
return err
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outline %q actions not transferred to OFD: %v", item.Title, err)})
|
||||
actions = nil
|
||||
}
|
||||
for depth > level {
|
||||
x.end("OutlineElem")
|
||||
depth--
|
||||
}
|
||||
attrs := ofdAttrs{{Name: xml.Name{Local: "Title"}, Value: item.Title}}
|
||||
attrs.add("Expanded", strconv.FormatBool(item.Expanded))
|
||||
x.start("OutlineElem", attrs)
|
||||
x.actions(actions)
|
||||
depth++
|
||||
count++
|
||||
return x.err
|
||||
})
|
||||
if err != nil {
|
||||
x.err = err
|
||||
return
|
||||
}
|
||||
for depth > 0 {
|
||||
x.end("OutlineElem")
|
||||
depth--
|
||||
}
|
||||
x.end("Outlines")
|
||||
})
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if p.warning == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errPDFMediaRead) {
|
||||
return err
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outlines not transferred to OFD: %v", err)})
|
||||
return nil
|
||||
}
|
||||
if count > 0 {
|
||||
p.editor.outlines = bytes.TrimPrefix(data, []byte(xml.Header))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// outlineActions 转换目录目标及动作链,不为无目标节点补充跳转
|
||||
// 入参: ctx 取消上下文, item PDF目录节点
|
||||
// 返回: []Action OFD动作序列, error 动作或取消错误
|
||||
func (p *pdfImporter) outlineActions(ctx context.Context, item pdfgo.OutlineItem) ([]Action, error) {
|
||||
object, err := p.reader.Resolve(item.Action)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dest, err := p.reader.Resolve(item.Destination)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dest != nil {
|
||||
if object != nil {
|
||||
return nil, fmt.Errorf("PDF outline has both destination and action")
|
||||
}
|
||||
object = pdfgo.Dictionary{"S": pdfgo.Name("GoTo"), "D": dest}
|
||||
}
|
||||
currentPage := -1
|
||||
var actions []Action
|
||||
err = p.reader.WalkActions(ctx, object, func(info pdfgo.ActionInfo) error {
|
||||
converted, err := p.linkActions(pdfgo.Annotation{Dictionary: pdfgo.Dictionary{"A": info.Dictionary}}, p.warning == nil, ¤tPage)
|
||||
if err != nil {
|
||||
if errors.Is(err, errPDFMediaRead) {
|
||||
return err
|
||||
}
|
||||
var unsupported *pdfgo.UnsupportedError
|
||||
if p.warning != nil && (errors.As(err, &unsupported) || errors.Is(err, pdfgo.ErrInvalidDestination) || errors.Is(err, pdfgo.ErrInvalidAction)) {
|
||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outline action not transferred to OFD: %v", err)})
|
||||
currentPage = -1
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if converted != nil {
|
||||
actions = append(actions, converted...)
|
||||
} else {
|
||||
currentPage = -1
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return actions, err
|
||||
}
|
||||
@@ -17,6 +17,7 @@ package ofdgo
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf"
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
@@ -33,6 +35,22 @@ const (
|
||||
pdfStructureNamespace = "https://ofdgo.aite.me/ns/pdf/structure/1"
|
||||
)
|
||||
|
||||
// PDF元数据使用的RDF、Dublin Core及Adobe命名空间
|
||||
const (
|
||||
pdfMetadataRDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
pdfMetadataDC = "http://purl.org/dc/elements/1.1/"
|
||||
pdfMetadataXMP = "http://ns.adobe.com/xap/1.0/"
|
||||
pdfMetadataPDF = "http://ns.adobe.com/pdf/1.3/"
|
||||
pdfMetadataXML = "adobe:ns:meta/"
|
||||
)
|
||||
|
||||
// PDF来源元数据的标引定义
|
||||
const (
|
||||
pdfMetadataTagType = "OFDGo.PDFMetadata"
|
||||
pdfInformationTagType = "OFDGo.PDFInformation"
|
||||
pdfInformationNamespace = "https://ofdgo.aite.me/ns/pdf/information/1"
|
||||
)
|
||||
|
||||
// pdfStructureBindings 保存来源区段与最终页面图元的关联,不记录临时渲染对象
|
||||
type pdfStructureBindings struct {
|
||||
parents map[*pdfgo.Reader]map[[2]int64]pdfgo.Reference
|
||||
@@ -93,6 +111,12 @@ type pdfStructureValues struct {
|
||||
objects []StructureNode
|
||||
}
|
||||
|
||||
// pdfMetadataValue 保留字段是否声明及其来源,空文本不作为缺省值
|
||||
type pdfMetadataValue struct {
|
||||
text string
|
||||
xmp bool
|
||||
}
|
||||
|
||||
// prepareStructure 校验结构内容关联并登记引用区段,不提前写入标引
|
||||
// 入参: ctx 取消上下文
|
||||
// 返回: error 结构或内容容器错误
|
||||
@@ -882,3 +906,277 @@ func (b *pdfStructureBindings) reference(pageID string, sequence *pdfgo.ContentS
|
||||
b.sequences[sequence] = refs
|
||||
return &refs[len(refs)-1]
|
||||
}
|
||||
|
||||
// documentMetadata 保留PDF元数据原文并映射文档描述,来源软件与扩展字段保存在独立标引中
|
||||
// 入参: ctx 取消上下文
|
||||
// 返回: error 读取、解析、日期或提交错误
|
||||
func (p *pdfImporter) documentMetadata(ctx context.Context) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := p.reader.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
values, original, err := p.informationMetadata(ctx, info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stream, err := p.reader.Metadata()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var packet []byte
|
||||
var root *StructureNode
|
||||
if stream != nil {
|
||||
packet, err = stream.DecodeContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root, err = parseStructure(ctx, packet, CustomTag{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
xmp, err := pdfMetadataFields(ctx, root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newer := false
|
||||
if stamp, ok := xmp["MetadataDate"]; ok && stamp.text != "" && info.ModDate != "" {
|
||||
date, _, zoned, err := codecpdf.MetadataDate(stamp.text, stamp.xmp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
modified, err := pdfgo.ParseDate(info.ModDate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newer = zoned && modified.After(date)
|
||||
}
|
||||
for key, value := range xmp {
|
||||
if _, exists := values[key]; !newer || !exists {
|
||||
values[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
projected := p.editor.Info
|
||||
for _, field := range []struct {
|
||||
name string
|
||||
text *string
|
||||
}{{"Title", &projected.Title}, {"Author", &projected.Author}, {"Subject", &projected.Subject}} {
|
||||
if value, ok := values[field.name]; ok && structureXMLText(value.text) {
|
||||
*field.text = value.text
|
||||
}
|
||||
}
|
||||
if value, ok := values["Keywords"]; ok && structureXMLText(value.text) {
|
||||
projected.Keywords = Keywords{value.text}
|
||||
}
|
||||
for _, field := range []struct {
|
||||
name string
|
||||
text *string
|
||||
}{{"CreationDate", &projected.CreationDate}, {"ModDate", &projected.ModDate}} {
|
||||
if value, ok := values[field.name]; ok {
|
||||
date, day, _, err := codecpdf.MetadataDate(value.text, value.xmp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("PDF metadata %s: %w", field.name, err)
|
||||
}
|
||||
*field.text = ""
|
||||
if day {
|
||||
*field.text = date.Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
p.editor.SetInfo(projected)
|
||||
if root != nil {
|
||||
if _, err := p.editor.addStructureXML(ctx, CustomTag{TypeID: pdfMetadataTagType, NameSpace: root.Namespace}, packet); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(original.Children) != 0 {
|
||||
data, err := encodeStructureNode(original)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = p.editor.addStructureXML(ctx, CustomTag{TypeID: pdfInformationTagType, NameSpace: pdfInformationNamespace}, data)
|
||||
return err
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// informationMetadata 收集已声明字段并保存原始文本,XML无法表示的字符使用Base64保留
|
||||
// 入参: ctx 取消上下文, info 已解码的信息字典
|
||||
// 返回: map[string]pdfMetadataValue 可映射字段, StructureNode 原始字段, error 读取或取消错误
|
||||
func (p *pdfImporter) informationMetadata(ctx context.Context, info pdfgo.DocumentInfo) (map[string]pdfMetadataValue, StructureNode, error) {
|
||||
root := StructureNode{Name: "Information", Namespace: pdfInformationNamespace}
|
||||
values := make(map[string]pdfMetadataValue)
|
||||
object, err := p.reader.Resolve(p.reader.Trailer["Info"])
|
||||
if err != nil || object == nil {
|
||||
return values, root, err
|
||||
}
|
||||
dict, ok := object.(pdfgo.Dictionary)
|
||||
if !ok {
|
||||
return nil, root, fmt.Errorf("invalid PDF information dictionary")
|
||||
}
|
||||
known := map[pdfgo.Name]string{
|
||||
"Title": info.Title, "Author": info.Author, "Subject": info.Subject, "Keywords": info.Keywords,
|
||||
"Creator": info.Creator, "Producer": info.Producer, "CreationDate": info.CreationDate, "ModDate": info.ModDate,
|
||||
"Trapped": string(info.Trapped),
|
||||
}
|
||||
for _, key := range slices.Sorted(maps.Keys(dict)) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, root, err
|
||||
}
|
||||
value, err := p.reader.Resolve(dict[key])
|
||||
if err != nil {
|
||||
return nil, root, err
|
||||
}
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
text, standard := known[key]
|
||||
if !standard {
|
||||
text = info.Custom[key]
|
||||
} else {
|
||||
values[string(key)] = pdfMetadataValue{text: text}
|
||||
}
|
||||
name := string(key)
|
||||
field := StructureNode{Name: "Field", Namespace: pdfInformationNamespace}
|
||||
field.Attributes = append(field.Attributes, StructureAttribute{Name: "Name", Value: name})
|
||||
if !structureXMLText(name) {
|
||||
field.Attributes[0].Value = base64.StdEncoding.EncodeToString([]byte(name))
|
||||
field.Attributes = append(field.Attributes, StructureAttribute{Name: "NameEncoding", Value: "base64"})
|
||||
}
|
||||
if key == "Trapped" {
|
||||
field.Attributes = append(field.Attributes, StructureAttribute{Name: "Type", Value: "name"})
|
||||
}
|
||||
if !structureXMLText(text) {
|
||||
text = base64.StdEncoding.EncodeToString([]byte(text))
|
||||
field.Attributes = append(field.Attributes, StructureAttribute{Name: "Encoding", Value: "base64"})
|
||||
}
|
||||
field.Text = text
|
||||
root.Children = append(root.Children, field)
|
||||
}
|
||||
return values, root, nil
|
||||
}
|
||||
|
||||
// pdfMetadataFields 读取文档级RDF描述,日期及PDF关键词不与其他同名扩展混用
|
||||
// 入参: ctx 取消上下文, root 元数据根节点
|
||||
// 返回: map[string]pdfMetadataValue 已声明的对应字段, error 取消错误
|
||||
func pdfMetadataFields(ctx context.Context, root *StructureNode) (map[string]pdfMetadataValue, error) {
|
||||
result := make(map[string]pdfMetadataValue)
|
||||
var rdf *StructureNode
|
||||
if root.Namespace == pdfMetadataRDF && root.Name == "RDF" {
|
||||
rdf = root
|
||||
} else if root.Namespace == pdfMetadataXML && (root.Name == "xmpmeta" || root.Name == "xapmeta") {
|
||||
for i := range root.Children {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
child := &root.Children[i]
|
||||
if child.Namespace == pdfMetadataRDF && child.Name == "RDF" {
|
||||
rdf = child
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if rdf == nil {
|
||||
return result, ctx.Err()
|
||||
}
|
||||
fields := map[xml.Name]string{
|
||||
{Space: pdfMetadataDC, Local: "title"}: "Title", {Space: pdfMetadataDC, Local: "creator"}: "Author",
|
||||
{Space: pdfMetadataDC, Local: "description"}: "Subject", {Space: pdfMetadataPDF, Local: "Keywords"}: "Keywords",
|
||||
{Space: pdfMetadataXMP, Local: "CreateDate"}: "CreationDate", {Space: pdfMetadataXMP, Local: "ModifyDate"}: "ModDate",
|
||||
{Space: pdfMetadataXMP, Local: "MetadataDate"}: "MetadataDate",
|
||||
}
|
||||
for _, description := range rdf.Children {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if description.Namespace != pdfMetadataRDF || description.Name != "Description" {
|
||||
continue
|
||||
}
|
||||
for _, attr := range description.Attributes {
|
||||
if key, ok := fields[xml.Name{Space: attr.Namespace, Local: attr.Name}]; ok && attr.Namespace != pdfMetadataDC {
|
||||
result[key] = pdfMetadataValue{text: attr.Value, xmp: true}
|
||||
}
|
||||
}
|
||||
for _, property := range description.Children {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, ok := fields[xml.Name{Space: property.Namespace, Local: property.Name}]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
arrayValue := property.Namespace == pdfMetadataDC
|
||||
property = pdfMetadataProperty(property)
|
||||
if arrayValue {
|
||||
kind := "Alt"
|
||||
if key == "Author" {
|
||||
kind = "Seq"
|
||||
}
|
||||
found := false
|
||||
for _, array := range property.Children {
|
||||
if array.Namespace == pdfMetadataRDF && array.Name == kind && len(array.Children) != 0 {
|
||||
item := array.Children[0]
|
||||
if item.Namespace == pdfMetadataRDF && item.Name == "li" {
|
||||
property = item
|
||||
found = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if text, ok := pdfMetadataText(property); ok {
|
||||
result[key] = pdfMetadataValue{text: text, xmp: true}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, ctx.Err()
|
||||
}
|
||||
|
||||
// pdfMetadataProperty 提取带限定符的rdf:value,保留数组及结构边界
|
||||
// 入参: node 属性节点
|
||||
// 返回: StructureNode 属性值节点
|
||||
func pdfMetadataProperty(node StructureNode) StructureNode {
|
||||
for {
|
||||
if len(node.Children) == 1 && node.Children[0].Namespace == pdfMetadataRDF && node.Children[0].Name == "Description" {
|
||||
node = node.Children[0]
|
||||
continue
|
||||
}
|
||||
for _, attr := range node.Attributes {
|
||||
if attr.Namespace == pdfMetadataRDF && attr.Name == "value" {
|
||||
return StructureNode{Text: attr.Value}
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, child := range node.Children {
|
||||
if child.Namespace == pdfMetadataRDF && child.Name == "value" {
|
||||
node, found = child, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return node
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pdfMetadataText 读取简单值,不拼接结构或数组文本,也不将资源URI当作文本
|
||||
// 入参: node 属性节点
|
||||
// 返回: string 文本, bool 是否为支持的简单值
|
||||
func pdfMetadataText(node StructureNode) (string, bool) {
|
||||
node = pdfMetadataProperty(node)
|
||||
for _, attr := range node.Attributes {
|
||||
if attr.Namespace == pdfMetadataRDF && (attr.Name == "resource" || attr.Name == "nodeID" || attr.Name == "parseType") {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return node.Text, len(node.Children) == 0
|
||||
}
|
||||
@@ -15,16 +15,48 @@
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
basefont "github.com/xiaoqidun/ofdgo/internal/base/font"
|
||||
codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf"
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// pdfImportedFont 保存单次导入共用的字体资源、度量及源字形映射,不保存文档标识
|
||||
type pdfImportedFont struct {
|
||||
resource *FontResource
|
||||
checksum [32]byte
|
||||
metrics FontMetrics
|
||||
type1Glyphs map[string]uint16
|
||||
repairLimit uint16
|
||||
}
|
||||
|
||||
// pdfTextReplacement 记录一个完整替换区段的文字数量与转换状态
|
||||
type pdfTextReplacement struct {
|
||||
text *pdfgo.ReplacementText
|
||||
count int
|
||||
matched int
|
||||
offset int64
|
||||
blocked bool
|
||||
applied bool
|
||||
identity bool
|
||||
}
|
||||
|
||||
// pdfTextReplacements 按绘制顺序收集替换区段,避免拆分或重复写入原文
|
||||
type pdfTextReplacements struct {
|
||||
importer *pdfImporter
|
||||
spans map[*pdfgo.ReplacementText]*pdfTextReplacement
|
||||
order []*pdfTextReplacement
|
||||
widget bool
|
||||
}
|
||||
|
||||
// text 保留字形及逐字基线,外部字体优先复用同名内嵌资源
|
||||
// 入参: mark PDF文字绘制信息
|
||||
// 返回: error 错误信息
|
||||
@@ -509,3 +541,463 @@ func (p *pdfImporter) disjointText(node pdfCompositeNode) (bool, error) {
|
||||
cache.textDisjoint[node.group] = true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// shapePDFText 使用导入字体整形注释文字,将毫米坐标约定转换为PDF文字空间方向
|
||||
// 入参: ctx 取消上下文, source 源字体, text 单行原文, rightToLeft 段落方向
|
||||
// 返回: []pdfgo.ShapedGlyph 千分之一字号的定位字形, error 字体、整形或取消错误
|
||||
func (p *pdfImporter) shapePDFText(ctx context.Context, source *pdfgo.Font, text string, rightToLeft bool) ([]pdfgo.ShapedGlyph, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(source.Program) == 0 {
|
||||
return nil, &pdfgo.UnsupportedError{Feature: "annotation shaping without embedded font"}
|
||||
}
|
||||
font, err := p.importedFont(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shaper, ok := font.metrics.(FontShaperOptions)
|
||||
if !ok {
|
||||
return nil, &pdfgo.UnsupportedError{Feature: "annotation text shaping backend"}
|
||||
}
|
||||
options := TextShapeOptions{Bidi: true}
|
||||
if rightToLeft {
|
||||
options.Direction = "rtl"
|
||||
}
|
||||
glyphs, err := shaper.ShapeTextWithOptions(text, 1000, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]pdfgo.ShapedGlyph, len(glyphs))
|
||||
units := float64(font.metrics.UnitsPerEm())
|
||||
if units <= 0 {
|
||||
return nil, fmt.Errorf("invalid annotation font units")
|
||||
}
|
||||
for i, glyph := range glyphs {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[i] = pdfgo.ShapedGlyph{ID: glyph.Glyph, Cluster: glyph.Cluster, VisualOrder: glyph.VisualOrder, X: glyph.X, Y: -glyph.Y, Advance: glyph.Advance, Width: float64(font.metrics.GlyphAdvance(glyph.Glyph)) * 1000 / units}
|
||||
}
|
||||
return result, ctx.Err()
|
||||
}
|
||||
|
||||
// embeddedTextFont 匹配同名内嵌字体,仅复用唯一且覆盖全部字符的字形程序
|
||||
// 入参: source 外部字体, glyphs 当前文字字形
|
||||
// 返回: *pdfImportedFont 可复用字体,无可靠匹配时为空
|
||||
func (p *pdfImporter) embeddedTextFont(source *pdfgo.Font, glyphs []pdfgo.Glyph) *pdfImportedFont {
|
||||
name := codecpdf.EmbeddedFontName(source.Name)
|
||||
if name == "" || len(glyphs) == 0 {
|
||||
return nil
|
||||
}
|
||||
var matched *pdfImportedFont
|
||||
for _, imported := range p.fonts {
|
||||
if codecpdf.EmbeddedFontName(imported.resource.Font.FontName) != name {
|
||||
continue
|
||||
}
|
||||
if matched != nil && matched.checksum != imported.checksum {
|
||||
return nil
|
||||
}
|
||||
matched = imported
|
||||
}
|
||||
if matched == nil {
|
||||
return nil
|
||||
}
|
||||
for _, glyph := range glyphs {
|
||||
if glyph.Name == ".notdef" || utf8.RuneCountInString(glyph.Text) != 1 {
|
||||
return nil
|
||||
}
|
||||
char, _ := utf8.DecodeRuneInString(glyph.Text)
|
||||
id := matched.metrics.GlyphIndex(char)
|
||||
if id == 0 || id >= matched.metrics.NumGlyphs() || matched.repairLimit != 0 && id >= matched.repairLimit {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
// importedFont 按原PDF字体复用包装和解析结果,失败结果不进入缓存
|
||||
// 入参: source 源字体
|
||||
// 返回: *pdfImportedFont 只读导入字体, error 字体程序或后端错误
|
||||
func (p *pdfImporter) importedFont(source *pdfgo.Font) (*pdfImportedFont, error) {
|
||||
if cached := p.fonts[source]; cached != nil {
|
||||
return cached, nil
|
||||
}
|
||||
var type1 *basefont.Type1Program
|
||||
result := &pdfImportedFont{}
|
||||
if source.ProgramType == "FontFile" {
|
||||
parsed, err := basefont.ParseType1Program(source.Program)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("PDF font %s program: %w", source.Name, err)
|
||||
}
|
||||
type1 = &parsed
|
||||
result.type1Glyphs = parsed.GlyphIDs()
|
||||
}
|
||||
program, repair, err := codecpdf.FontProgram(source, type1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("PDF font %s program: %w", source.Name, err)
|
||||
}
|
||||
if repair.Locations && p.warning == nil {
|
||||
return nil, fmt.Errorf("PDF font %s program: %w", source.Name, basefont.ErrTTInvalidLocations)
|
||||
}
|
||||
if p.editor.backends.FontResources == nil {
|
||||
return nil, fmt.Errorf("PDF font %s resource: font resources: %w", source.Name, ErrBackendUnavailable)
|
||||
}
|
||||
result.resource, err = p.editor.backends.FontResources.OpenFontResource(FontFile{Name: source.Name + ".ttf", Data: program}, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("PDF font %s resource: %w", source.Name, err)
|
||||
}
|
||||
if p.editor.backends.Fonts == nil {
|
||||
return nil, fmt.Errorf("PDF font backend unavailable")
|
||||
}
|
||||
result.metrics, err = p.editor.backends.Fonts.OpenFont(result.resource.Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("PDF font %s metrics: %w", source.Name, err)
|
||||
}
|
||||
if _, ok := result.metrics.(FontOutlines); !ok {
|
||||
return nil, fmt.Errorf("PDF font backend does not provide glyph outlines")
|
||||
}
|
||||
result.checksum, result.repairLimit = sha256.Sum256(result.resource.Data), repair.MetricsLimit
|
||||
if repair.Locations {
|
||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF font %s has invalid glyph locations; index rebuilt with original glyph IDs", source.Name)})
|
||||
}
|
||||
if p.fonts == nil {
|
||||
p.fonts = make(map[*pdfgo.Font]*pdfImportedFont)
|
||||
}
|
||||
p.fonts[source] = result
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// symbolFont 加载标准符号字体,保留原字形编号和源字形名称
|
||||
// 入参: source 未嵌入的PDF字体
|
||||
// 返回: *pdfImportedFont 可用的符号字体,无匹配时为空, error 字体或取消错误
|
||||
func (p *pdfImporter) symbolFont(source *pdfgo.Font) (*pdfImportedFont, error) {
|
||||
name := codecpdf.EmbeddedFontName(source.Name)
|
||||
if source.Subtype != "Type1" || name != "Symbol" && name != "ZapfDingbats" {
|
||||
return nil, nil
|
||||
}
|
||||
if imported, ok := p.symbolFonts[source]; ok {
|
||||
return imported, nil
|
||||
}
|
||||
if p.symbolFonts == nil {
|
||||
p.symbolFonts = make(map[*pdfgo.Font]*pdfImportedFont)
|
||||
}
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
definition := &Font{ID: name, FontName: name, FamilyName: name}
|
||||
reader := &Reader{doc: &Document{}, fontResourcesRead: true, fontCache: map[string]*Font{name: definition}}
|
||||
resolved, err := p.editor.newRenderer(reader).ResolveFont(name, true)
|
||||
if err != nil || len(resolved.Data) == 0 || p.editor.backends.FontResources == nil {
|
||||
p.symbolFonts[source] = nil
|
||||
return nil, nil
|
||||
}
|
||||
program, names, err := basefont.SymbolProgram(p.ctx, resolved.Data, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(names) == 0 {
|
||||
p.symbolFonts[source] = nil
|
||||
return nil, nil
|
||||
}
|
||||
resource, err := p.editor.backends.FontResources.OpenFontResource(FontFile{Name: name + ".ttf", Data: program}, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metrics, err := p.editor.backends.Fonts.OpenFont(resource.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := metrics.(FontOutlines); !ok {
|
||||
p.symbolFonts[source] = nil
|
||||
return nil, nil
|
||||
}
|
||||
result := &pdfImportedFont{resource: resource, checksum: sha256.Sum256(resource.Data), metrics: metrics, type1Glyphs: names}
|
||||
p.symbolFonts[source] = result
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// span 查找最外层替换区段,相邻区段即使原文相同也分别保留
|
||||
// 入参: text 替换文本
|
||||
// 返回: *pdfTextReplacement 区段状态,未声明时为空
|
||||
func (r *pdfTextReplacements) span(text *pdfgo.ReplacementText) *pdfTextReplacement {
|
||||
if text == nil {
|
||||
return nil
|
||||
}
|
||||
if r.spans == nil {
|
||||
r.spans = make(map[*pdfgo.ReplacementText]*pdfTextReplacement)
|
||||
}
|
||||
first := text
|
||||
for text.Parent != nil && r.spans[text] == nil {
|
||||
text = text.Parent
|
||||
}
|
||||
span := r.spans[text]
|
||||
if span == nil {
|
||||
span = &pdfTextReplacement{text: text}
|
||||
r.spans[text] = span
|
||||
r.order = append(r.order, span)
|
||||
}
|
||||
for first != text {
|
||||
r.spans[first] = span
|
||||
first = first.Parent
|
||||
}
|
||||
return span
|
||||
}
|
||||
|
||||
// block 标记无法仅用字符与字形变换表达的区段
|
||||
// 入参: text 替换文本
|
||||
func (r *pdfTextReplacements) block(text *pdfgo.ReplacementText) {
|
||||
if span := r.span(text); span != nil {
|
||||
span.blocked = true
|
||||
}
|
||||
}
|
||||
|
||||
// markedContent 处理结构区段、可选内容及替换文本属性
|
||||
// 入参: mark 内容标记
|
||||
// 返回: error 未支持的语义错误
|
||||
func (r *pdfTextReplacements) markedContent(mark pdfgo.MarkedContentMark) error {
|
||||
if mark.Operator == "EMC" {
|
||||
return nil
|
||||
}
|
||||
if r.importer.structureBindings != nil {
|
||||
if err := r.importer.prepareSourceStructure(r.importer.ctx, mark.Reader); err != nil {
|
||||
return err
|
||||
}
|
||||
r.importer.structureBindings.observe(mark.Sequence)
|
||||
if mark.Sequence != nil && r.importer.structureBindings.marked != nil {
|
||||
key, ok, err := pdfStructureContentKey(mark.Sequence)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok && r.importer.structureBindings.marked[key] {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if mark.Tag == "OC" {
|
||||
kind, err := mark.Reader.Resolve(mark.Properties["Type"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if kind == pdfgo.Name("OCG") || kind == pdfgo.Name("OCMD") {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if r.widget && mark.Tag == "Tx" && len(mark.Properties) == 0 {
|
||||
return nil
|
||||
}
|
||||
if mark.Replacement != nil && len(mark.Properties) == 1 {
|
||||
return nil
|
||||
}
|
||||
if r.importer.warning == nil {
|
||||
return &pdfgo.UnsupportedError{Feature: "marked content requiring semantic preservation"}
|
||||
}
|
||||
r.importer.warning(pdfgo.Diagnostic{Offset: mark.Offset, Message: "marked content " + string(mark.Tag) + " not preserved"})
|
||||
return nil
|
||||
}
|
||||
|
||||
// apply 合并可等价表示的替换区段,原文相同但样式不同的区段保留原图元
|
||||
// 入参: nodes 原始图元
|
||||
// 返回: []pdfCompositeNode 转换图元, error 取消或语义转换错误
|
||||
func (r *pdfTextReplacements) apply(nodes []pdfCompositeNode) ([]pdfCompositeNode, error) {
|
||||
if len(r.order) == 0 {
|
||||
return nodes, nil
|
||||
}
|
||||
if err := r.count(nodes, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, span := range r.order {
|
||||
span.identity = !span.blocked && span.count > 0 && span.matched == len(span.text.Text)
|
||||
}
|
||||
var err error
|
||||
nodes, err = r.merge(nodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, span := range r.order {
|
||||
if span.applied || span.identity || !span.blocked && span.count == 0 && span.text.Text == "" {
|
||||
continue
|
||||
}
|
||||
if r.importer.warning == nil {
|
||||
return nil, &pdfgo.UnsupportedError{Feature: "ActualText span without equivalent OFD glyph mapping"}
|
||||
}
|
||||
r.importer.warning(pdfgo.Diagnostic{Offset: span.offset, Message: "PDF ActualText span not preserved; original appearance retained"})
|
||||
}
|
||||
r.clear(nodes)
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// count 统计区段文字并逐字核对原文,排除需要组级合成的内容
|
||||
// 入参: nodes 图元, blocked 上层组是否阻止直接转换
|
||||
// 返回: error 取消错误
|
||||
func (r *pdfTextReplacements) count(nodes []pdfCompositeNode, blocked bool) error {
|
||||
for _, node := range nodes {
|
||||
if err := r.importer.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if node.text != nil {
|
||||
if span := r.span(node.text.Replacement); span != nil {
|
||||
span.count++
|
||||
span.blocked = span.blocked || blocked
|
||||
for i, glyph := range node.text.Glyphs {
|
||||
if span.matched < 0 {
|
||||
break
|
||||
}
|
||||
if i%256 == 0 {
|
||||
if err := r.importer.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if glyph.Text == "" || !strings.HasPrefix(span.text.Text[span.matched:], glyph.Text) {
|
||||
span.matched = -1
|
||||
break
|
||||
}
|
||||
span.matched += len(glyph.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
childBlocked := blocked
|
||||
if group := node.group; group != nil && node.textObject == nil {
|
||||
opaque, err := r.importer.opaque(node, group.ColorSpace)
|
||||
if err := r.importer.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
childBlocked = childBlocked || !opaque || err != nil || group.ColorSpace != nil && !group.ColorSpace.SRGBEquivalent()
|
||||
}
|
||||
if err := r.count(node.children, childBlocked); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// merge 合并同层连续图元,保留原始绘制顺序与字形坐标
|
||||
// 入参: nodes 图元
|
||||
// 返回: []pdfCompositeNode 合并结果, error 取消错误
|
||||
func (r *pdfTextReplacements) merge(nodes []pdfCompositeNode) ([]pdfCompositeNode, error) {
|
||||
result := nodes[:0]
|
||||
for i := 0; i < len(nodes); {
|
||||
if err := r.importer.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node := nodes[i]
|
||||
var err error
|
||||
node.children, err = r.merge(node.children)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
end := i + 1
|
||||
combined := false
|
||||
first := node.text
|
||||
if first == nil && node.textObject != nil && len(node.children) != 0 {
|
||||
first = node.children[0].text
|
||||
}
|
||||
if first != nil {
|
||||
if span := r.span(first.Replacement); span != nil && !span.applied && !span.blocked && span.text.Text != "" && !(span.identity && span.count == 1) {
|
||||
var marks []*pdfgo.TextMark
|
||||
for end = i; end < len(nodes); end++ {
|
||||
current := nodes[end]
|
||||
children := current.children
|
||||
if current.text != nil {
|
||||
children = nodes[end : end+1]
|
||||
} else if current.textObject == nil {
|
||||
break
|
||||
}
|
||||
valid := len(children) != 0
|
||||
for _, child := range children {
|
||||
if child.text == nil || r.span(child.text.Replacement) != span {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
break
|
||||
}
|
||||
for _, child := range children {
|
||||
marks = append(marks, child.text)
|
||||
}
|
||||
}
|
||||
end = max(end, i+1)
|
||||
if len(marks) == span.count {
|
||||
if mark, ok := codecpdf.MergeReplacement(marks, span.text); ok {
|
||||
var origins pdfOriginSet
|
||||
for _, source := range marks {
|
||||
if err := r.importer.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
origin := r.importer.textOrigin(*source)
|
||||
origins.add(origin.sequence)
|
||||
for _, sequence := range origin.related {
|
||||
origins.add(sequence)
|
||||
}
|
||||
}
|
||||
if len(origins.origin.related) != 0 {
|
||||
if r.importer.textOrigins == nil {
|
||||
r.importer.textOrigins = make(map[*pdfgo.ReplacementText]pdfObjectOrigin)
|
||||
}
|
||||
r.importer.textOrigins[span.text] = origins.origin
|
||||
}
|
||||
if node.text != nil {
|
||||
node.text = mark
|
||||
} else {
|
||||
node.children = []pdfCompositeNode{{text: mark}}
|
||||
}
|
||||
span.applied = true
|
||||
combined = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
node.formCount = 0
|
||||
result = append(result, node)
|
||||
if !combined {
|
||||
for _, remaining := range nodes[i+1 : end] {
|
||||
remaining.formCount = 0
|
||||
result = append(result, remaining)
|
||||
}
|
||||
}
|
||||
i = end
|
||||
}
|
||||
clear(nodes[len(result):])
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// clear 清除无需替换及未转换的区段引用,防止局部绘制重复使用整段原文
|
||||
// 入参: nodes 图元
|
||||
func (r *pdfTextReplacements) clear(nodes []pdfCompositeNode) {
|
||||
for _, node := range nodes {
|
||||
if node.text != nil {
|
||||
if span := r.span(node.text.Replacement); span != nil && !span.applied {
|
||||
node.text.Replacement = nil
|
||||
}
|
||||
}
|
||||
r.clear(node.children)
|
||||
}
|
||||
}
|
||||
|
||||
// textOrigin 获取合并文字的完整来源,普通文字沿用原始区段
|
||||
// 入参: mark 文字图元
|
||||
// 返回: pdfObjectOrigin 来源区段
|
||||
func (p *pdfImporter) textOrigin(mark pdfgo.TextMark) pdfObjectOrigin {
|
||||
if origin, ok := p.textOrigins[mark.Replacement]; ok {
|
||||
return origin
|
||||
}
|
||||
return pdfObjectOrigin{sequence: mark.Sequence}
|
||||
}
|
||||
|
||||
// pdfApplyReplacement 将整段原文映射到全部字形,字形位移遵循GB/T 33190的文字定位规则
|
||||
// 入参: object 文字对象, text 整段原文, positions 原始字形位置
|
||||
func pdfApplyReplacement(object *TextObject, text string, positions []pdfgo.Point) {
|
||||
const unit = 25.4 / 72
|
||||
glyphs := make([]string, len(object.CGTransform))
|
||||
for i, transform := range object.CGTransform {
|
||||
glyphs[i] = transform.Glyphs
|
||||
}
|
||||
dx, dy := make([]float64, len(positions)-1), make([]float64, len(positions)-1)
|
||||
for i := 1; i < len(positions); i++ {
|
||||
dx[i-1] = (positions[i].X - positions[i-1].X) * unit
|
||||
dy[i-1] = -(positions[i].Y - positions[i-1].Y) * unit
|
||||
}
|
||||
object.TextCode = []TextCode{{X: pdfNumbers(positions[0].X * unit), Y: pdfNumbers(-positions[0].Y * unit), DeltaX: pdfNumbers(dx...), DeltaY: pdfNumbers(dy...), Value: escapeOFDText(text)}}
|
||||
object.CGTransform = []CGTransform{{CodeCount: utf8.RuneCountInString(text), GlyphCount: len(glyphs), Glyphs: strings.Join(glyphs, " ")}}
|
||||
}
|
||||
@@ -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, " ")}}
|
||||
}
|
||||
Reference in new issue
Block a user