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