Files
ofdgo/ofdgo_pdf.go
T
2026-10-18 18:08:52 +08:00

653 lines
23 KiB
Go

// 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"
"fmt"
"io"
"slices"
"sort"
codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf"
"github.com/xiaoqidun/pdfgo"
)
// pdfObjectChunkLimit 限制导入暂存片段的容量,不限制页面对象数量
const pdfObjectChunkLimit = 256
// ErrPDFPassword 表示PDF需要正确的用户或所有者密码
var ErrPDFPassword = pdfgo.ErrPassword
// PDFImportOptions 指定PDF转换的密码、运行时后端、进度通知与检查策略
// Password默认使用PDF安全处理器编码,PasswordUTF8启用UTF-8输入及标准密码准备,空值尝试空密码
// Strict禁止恢复缺失资源或丢失内容语义,默认在报告中记录警告
// RendererOptions复用渲染器字体来源和后端配置,Backends优先于其中的后端设置
// RasterDPI指定局部合成与三维绘制精度,0沿用渲染器DPI,默认300dpi,Strict禁用局部合成
// OnProgress按open、pages、convert及write.*阶段报告进度,total为0表示总量未知
// ResolveFile读取PDF引用的外部媒体、附件、页面及三维纹理,默认不访问网络或本地路径
// ResolveReference可提供已解密的引用页面,目标阅读器由调用方维护
// Halftones提供只读设备命名网屏,所属阅读器由调用方维护,连续色调输出不网屏化
// Compression配置转换输出,有损模式按图片实际尺寸降采样,原始及无损模式保持完整采样
type PDFImportOptions struct {
Password []byte
PasswordUTF8 bool
Backends *RenderBackends
RendererOptions []RendererOption
Progress func(int) error
OnProgress func(stage string, completed, total int) error
Strict bool
RasterDPI float64
ResolveFile pdfgo.FileResolver
ResolveReference pdfgo.ReferenceResolver
Halftones map[string]*pdfgo.Halftone
Compression CompressionOptions
}
// PDFImportReport 汇总转换页数、对象数、链接数和转换警告
type PDFImportReport struct {
Pages int
TextObjects int
PathObjects int
ImageObjects int
Links int
Warnings []pdfgo.Diagnostic
}
// pdfImporter 保存单次转换的对象与资源状态
type pdfImporter struct {
ctx context.Context
reader *pdfgo.Reader
editor *Editor
renderer *Renderer
report PDFImportReport
matrix pdfgo.Matrix
page int
pageActionPage int
fontIDs map[*pdfgo.Font]string
fonts map[*pdfgo.Font]*pdfImportedFont
symbolFonts map[*pdfgo.Font]*pdfImportedFont
fontWarnings map[string]bool
clipTexts map[*pdfgo.TextClip][]TextObject
textOrigins map[*pdfgo.ReplacementText]pdfObjectOrigin
clipPaths *renderCache[[32]byte, pdfClipPath]
cmykSpace string
rgbSpace string
iccSpaces map[pdfICCKey]string
objects *pdfObjectBuffer
structureBindings *pdfStructureBindings
annotationBindings []pdfStructureObjectBinding
formResources *pdfFormCache
validation *editorValidation
pages map[pdfgo.Reference]*pdfgo.Page
pageOrder []*pdfgo.Page
pageIDs map[pdfgo.Reference]string
pageIndexes map[pdfgo.Reference]int
destinationBoxes map[pdfgo.Reference]Box
imageIDs map[pdfImageKey][]pdfImageResource
imageContents *renderCache[pdfImageContentKey, pdfImageContent]
attachmentIDs map[pdfAttachmentKey]string
modelAttachments map[*pdfgo.Stream]string
mediaResources map[pdfMediaFileKey]pdfMediaResource
mediaInstances map[pdfMediaInstanceKey]pdfMediaResource
movieBounds map[*Movie]Box
renditions *pdfRenditionImport
maskClips map[*pdfgo.SoftMask]pdfgo.Path
pageBox pdfgo.Rectangle
pageWidth float64
pageHeight float64
warning func(pdfgo.Diagnostic)
rasterDPI float64
rasterWarned bool
compositeCache *pdfCompositeCache
compositeNodes []pdfCompositeNode
compositeSpace *pdfgo.ColorSpace
patternCells *renderCache[pdfPatternCellKey, pdfPatternCell]
patternOrigins map[*Pattern]pdfObjectOrigin
annotationData []byte
resolveFile pdfgo.FileResolver
resolveReference pdfgo.ReferenceResolver
references *codecpdf.ReferenceCache
halftoneWarnings map[*pdfgo.Halftone]bool
halftones map[string]*pdfgo.Halftone
transferBackdrop bool
transferModel pdfgo.Name
compression CompressionOptions
}
// pdfObjectBuffer 分块收集导入对象,提交前只整理一次连续数据
type pdfObjectBuffer struct {
parts [][]GraphicObject
sequences []pdfObjectSequence
size int
}
// pdfObjectSequence 记录连续对象的来源区段,仅在区段变化时追加
type pdfObjectSequence struct {
start int
pdfObjectOrigin
}
// pdfObjectOrigin 保存图元的来源区段,混合区域同时保留参与合成的区段
type pdfObjectOrigin struct {
sequence *pdfgo.ContentSequence
related []*pdfgo.ContentSequence
}
// pdfOriginSet 按首次出现顺序收集来源,仅在出现不同区段时分配去重表
type pdfOriginSet struct {
origin pdfObjectOrigin
seen map[*pdfgo.ContentSequence]bool
started bool
}
// add 记录参与绘制的区段,空区段表示未标记内容
// 入参: sequence 来源区段
func (s *pdfOriginSet) add(sequence *pdfgo.ContentSequence) {
if !s.started {
s.origin.sequence, s.started = sequence, true
return
}
if sequence == s.origin.sequence || s.seen[sequence] {
return
}
if s.seen == nil {
s.seen = make(map[*pdfgo.ContentSequence]bool)
}
s.seen[sequence] = true
s.origin.related = append(s.origin.related, sequence)
}
// equal 比较来源及其顺序,不合并不同来源的图元
// 入参: other 待比较来源
// 返回: bool 是否相同
func (o pdfObjectOrigin) equal(other pdfObjectOrigin) bool {
return o.sequence == other.sequence && slices.Equal(o.related, other.related)
}
// merge 合并参与混色的来源,不修改已有区段切片
// 入参: other 另一来源
// 返回: pdfObjectOrigin 去重后的来源
func (o pdfObjectOrigin) merge(other pdfObjectOrigin) pdfObjectOrigin {
if o.equal(other) {
return o
}
result := o
copied := false
appendSequence := func(sequence *pdfgo.ContentSequence) {
if sequence != result.sequence && !slices.Contains(result.related, sequence) {
if !copied {
result.related = slices.Clone(result.related)
copied = true
}
result.related = append(result.related, sequence)
}
}
appendSequence(other.sequence)
for _, sequence := range other.related {
appendSequence(sequence)
}
return result
}
// ImportPDF 将PDF内容转换为独立OFD编辑文档,失败时不返回部分结果
// 入参: ctx 取消上下文, source PDF数据, size 字节数, options 转换选项
// 返回: *Editor 编辑文档, PDFImportReport 转换统计, error 错误信息
func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFImportOptions) (*Editor, PDFImportReport, error) {
if err := ctx.Err(); err != nil {
return nil, PDFImportReport{}, err
}
if err := options.Compression.Validate(); err != nil {
return nil, PDFImportReport{}, err
}
if !finite(options.RasterDPI) || options.RasterDPI < 0 {
return nil, PDFImportReport{}, fmt.Errorf("invalid PDF raster DPI")
}
renderer := NewRenderer(&Reader{}, options.RendererOptions...)
if options.Backends != nil {
WithRenderBackends(*options.Backends)(renderer)
}
if options.RasterDPI != 0 {
renderer.DPI = options.RasterDPI
}
if !finite(renderer.DPI) || renderer.DPI <= 0 {
return nil, PDFImportReport{}, fmt.Errorf("invalid PDF raster DPI")
}
progress := editorProgress(options.OnProgress).withContext(ctx)
if err := progress.report("open", 0, 0); err != nil {
return nil, PDFImportReport{}, err
}
editor := NewEditor()
editor.SetRenderBackends(renderer.Backends())
editor.fontDirs, editor.fontFS = renderer.FontSources()
editor.fontSourcesCache = renderer.fontSourcesCache
importer := pdfImporter{ctx: ctx, editor: editor, renderer: renderer, rasterDPI: renderer.DPI, fontIDs: map[*pdfgo.Font]string{}, fonts: map[*pdfgo.Font]*pdfImportedFont{}, pages: map[pdfgo.Reference]*pdfgo.Page{}, pageIDs: map[pdfgo.Reference]string{}, structureBindings: &pdfStructureBindings{}}
readOptions := pdfgo.ReaderOptions{Password: options.Password, PasswordUTF8: options.PasswordUTF8, ShapeText: importer.shapePDFText}
var diagnostics []pdfgo.Diagnostic
var readWarning func(pdfgo.Diagnostic)
if !options.Strict {
readWarning = func(diagnostic pdfgo.Diagnostic) { diagnostics = append(diagnostics, diagnostic) }
readOptions.Warning = func(diagnostic pdfgo.Diagnostic) { readWarning(diagnostic) }
}
reader, err := pdfgo.NewReaderWithOptions(convertReader{context: ctx, source: source}, size, readOptions)
if err != nil {
return nil, PDFImportReport{}, err
}
defer reader.Close()
importer.reader = reader
if err := importer.prepareStructure(ctx); err != nil {
return nil, PDFImportReport{}, fmt.Errorf("import PDF structure: %w", err)
}
outputIntents, err := reader.OutputIntents()
if err != nil {
return nil, PDFImportReport{}, fmt.Errorf("import PDF output intents: %w", err)
}
importer.report.Warnings = diagnostics
if len(outputIntents) != 0 {
if options.Strict {
return nil, PDFImportReport{}, &pdfgo.UnsupportedError{Feature: "PDF output intent conversion"}
}
importer.report.Warnings = append(importer.report.Warnings, pdfgo.Diagnostic{Message: "PDF output intents not transferred to OFD"})
}
importer.pageIndexes = make(map[pdfgo.Reference]int)
importer.resolveFile = options.ResolveFile
importer.resolveReference = options.ResolveReference
importer.halftones = options.Halftones
importer.compression = options.Compression
importer.references = &codecpdf.ReferenceCache{}
defer importer.references.Close()
importer.halftoneWarnings = make(map[*pdfgo.Halftone]bool)
if !options.Strict {
importer.warning = func(warning pdfgo.Diagnostic) {
warning.Page = importer.page + 1
importer.report.Warnings = append(importer.report.Warnings, warning)
}
readWarning = importer.warning
}
if err := progress.report("pages", 0, 0); err != nil {
return nil, PDFImportReport{}, err
}
err = reader.WalkPages(ctx, func(index int, page *pdfgo.Page) error {
_, width, height := codecpdf.PageMatrix(page)
if _, err := editor.AddPage(width, height); err != nil {
return err
}
importer.pages[page.Reference] = page
importer.pageOrder = append(importer.pageOrder, page)
importer.pageIDs[page.Reference] = editor.pages[index].ID
importer.pageIndexes[page.Reference] = index
return progress.report("pages", index+1, 0)
})
if err != nil {
return nil, PDFImportReport{}, err
}
if len(editor.pages) != 0 {
importer.page = -1
if err := importer.documentMetadata(ctx); err != nil {
return nil, PDFImportReport{}, fmt.Errorf("import PDF metadata: %w", err)
}
if err := importer.documentAttachments(ctx); err != nil {
return nil, PDFImportReport{}, fmt.Errorf("import PDF attachments: %w", err)
}
if err := importer.outlines(ctx); err != nil {
return nil, PDFImportReport{}, fmt.Errorf("import PDF outlines: %w", err)
}
if err := importer.documentOpenActions(ctx); err != nil {
return nil, PDFImportReport{}, fmt.Errorf("import PDF document actions: %w", err)
}
importer.page = 0
}
if err := progress.report("convert", 0, len(editor.pages)); err != nil {
return nil, PDFImportReport{}, err
}
err = reader.WalkPages(ctx, func(index int, page *pdfgo.Page) error {
matrix, width, height := codecpdf.PageMatrix(page)
importer.matrix = matrix
importer.page = index
importer.rasterWarned = false
importer.pageBox = page.CropBox
importer.pageWidth = width
importer.pageHeight = height
importer.maskClips = make(map[*pdfgo.SoftMask]pdfgo.Path)
importer.compositeCache = nil
importer.compositeNodes = nil
importer.compositeSpace = nil
importer.patternCells = nil
importer.patternOrigins = nil
importer.clipTexts = make(map[*pdfgo.TextClip][]TextObject)
importer.textOrigins = nil
importer.clipPaths = nil
importer.annotationData = nil
importer.annotationBindings = nil
if err := importer.pageOpenActions(ctx, page); err != nil {
return fmt.Errorf("import PDF page %d actions: %w", index+1, err)
}
if err := importer.compositePage(nil, func(visitor pdfgo.Visitor) error { return reader.WalkPage(ctx, page, visitor) }); err != nil {
return fmt.Errorf("import PDF page %d: %w", index+1, err)
}
if err := importer.annotations(ctx, page, options.Strict); err != nil {
return fmt.Errorf("import PDF page %d: %w", index+1, err)
}
if err := importer.commitAnnotations(); err != nil {
return fmt.Errorf("import PDF page %d: %w", index+1, err)
}
if err := importer.commitObjects(); err != nil {
return fmt.Errorf("import PDF page %d: %w", index+1, err)
}
importer.report.Pages++
if err := progress.report("convert", index+1, len(editor.pages)); err != nil {
return err
}
if options.Progress != nil {
if err := options.Progress(index + 1); err != nil {
return err
}
}
return ctx.Err()
})
if err != nil {
return nil, PDFImportReport{}, err
}
if importer.report.Pages == 0 {
return nil, PDFImportReport{}, fmt.Errorf("PDF contains no pages")
}
if err := importer.documentStructures(ctx); err != nil {
return nil, PDFImportReport{}, fmt.Errorf("import PDF structure: %w", err)
}
return editor, importer.report, nil
}
// ConvertPDF 将PDF转换并写入OFD,转换阶段失败时不写入目标
// 入参: ctx 取消上下文, source PDF数据, size 字节数, output OFD输出, options 转换选项
// 返回: PDFImportReport 转换统计, error 错误信息
func ConvertPDF(ctx context.Context, source io.ReaderAt, size int64, output io.Writer, options PDFImportOptions) (PDFImportReport, error) {
editor, report, err := ImportPDF(ctx, source, size, options)
if err != nil {
return PDFImportReport{}, err
}
if err := ctx.Err(); err != nil {
return PDFImportReport{}, err
}
if options.OnProgress != nil {
if err := options.OnProgress("write", 0, 0); err != nil {
return PDFImportReport{}, err
}
}
editor.OnWriteProgress = func(stage string, completed, total int) error {
if err := ctx.Err(); err != nil {
return err
}
if options.OnProgress != nil {
return options.OnProgress("write."+stage, completed, total)
}
return nil
}
_, err = editor.WriteToWithOptions(ctx, output, WriteOptions{Compression: options.Compression})
return report, err
}
// referencePage 衔接PDF访问器与当前导入会话的引用文档缓存
// 入参: ctx 取消上下文, source 来源文档, reference 引用目标
// 返回: *pdfgo.Page 目标页面,不可用时为空, error 取消或调用方解析错误
func (p *pdfImporter) referencePage(ctx context.Context, source *pdfgo.Reader, reference pdfgo.ReferenceXObject) (*pdfgo.Page, error) {
if p.references == nil {
p.references = &codecpdf.ReferenceCache{}
}
return p.references.Resolve(ctx, source, reference, p.resolveFile, p.resolveReference, p.warning)
}
// objectValidation 在单次导入中共享不可变资源的校验结果,隔离局部编辑器
// 返回: *editorValidation 当前编辑器的构建校验会话
func (p *pdfImporter) objectValidation() *editorValidation {
if p.validation == nil || p.validation.Editor != p.editor {
p.validation = &editorValidation{Editor: p.editor}
}
return p.validation
}
// appendObject 追加本次导入对象,按需建立独立暂存缓冲
// 入参: object 独立导入对象
func (p *pdfImporter) appendObject(object GraphicObject) {
p.appendSequenceObject(object, nil)
}
// appendSequenceObject 追加对象并保留来源区段,供复合对象优化核对边界
// 入参: object 独立导入对象, sequence 来源区段
func (p *pdfImporter) appendSequenceObject(object GraphicObject, sequence *pdfgo.ContentSequence) {
p.appendOriginObject(object, pdfObjectOrigin{sequence: sequence})
}
// appendOriginObject 追加对象并保留全部来源区段
// 入参: object 独立导入对象, origin 来源区段
func (p *pdfImporter) appendOriginObject(object GraphicObject, origin pdfObjectOrigin) {
if len(p.patternOrigins) != 0 {
var fill *FillColor
var stroke *StrokeColor
switch object.Type {
case "PathObject":
fill, stroke = object.PathObject.FillColor, object.PathObject.StrokeColor
case "TextObject":
fill, stroke = object.TextObject.FillColor, object.TextObject.StrokeColor
}
if fill != nil {
if source, ok := p.patternOrigins[fill.Pattern]; ok {
origin = origin.merge(source)
}
}
if stroke != nil {
if source, ok := p.patternOrigins[stroke.Pattern]; ok {
origin = origin.merge(source)
}
}
}
if p.objects == nil {
p.objects = &pdfObjectBuffer{}
}
p.objects.appendOrigin(object, origin)
}
// append 追加对象,小批量连续存储,超过阈值后不复制前序片段
// 入参: object 独立对象
func (b *pdfObjectBuffer) append(object GraphicObject) {
b.appendSequence(object, nil)
}
// appendSequence 按区段连续存储对象,不为未标记内容分配区段记录
// 入参: object 独立对象, sequence 来源区段
func (b *pdfObjectBuffer) appendSequence(object GraphicObject, sequence *pdfgo.ContentSequence) {
b.appendOrigin(object, pdfObjectOrigin{sequence: sequence})
}
// appendOrigin 按来源连续存储对象,混合区域保留多个区段
// 入参: object 独立对象, origin 来源区段
func (b *pdfObjectBuffer) appendOrigin(object GraphicObject, origin pdfObjectOrigin) {
b.recordOrigin(b.size, origin)
index := len(b.parts) - 1
if index == 0 && len(b.parts[0]) == cap(b.parts[0]) && cap(b.parts[0]) < pdfObjectChunkLimit {
part := b.parts[0]
next := make([]GraphicObject, len(part), min(pdfObjectChunkLimit, max(1, cap(part)*2)))
copy(next, part)
b.parts[0] = next
}
if index < 0 || len(b.parts[index]) == cap(b.parts[index]) {
capacity := pdfObjectChunkLimit
if index < 0 {
capacity = 1
}
b.parts = append(b.parts, make([]GraphicObject, 0, capacity))
index++
}
b.parts[index] = append(b.parts[index], object)
b.size++
}
// recordOrigin 按递增对象位置记录完整来源变化
// 入参: index 对象位置, origin 来源区段
func (b *pdfObjectBuffer) recordOrigin(index int, origin pdfObjectOrigin) {
var previous pdfObjectOrigin
if len(b.sequences) != 0 {
previous = b.sequences[len(b.sequences)-1].pdfObjectOrigin
}
if !previous.equal(origin) {
b.sequences = append(b.sequences, pdfObjectSequence{start: index, pdfObjectOrigin: origin})
}
}
// origin 获取暂存对象的全部来源,未标记对象或空缓冲返回空来源
// 入参: index 对象位置
// 返回: pdfObjectOrigin 来源区段
func (b *pdfObjectBuffer) origin(index int) pdfObjectOrigin {
if b == nil {
return pdfObjectOrigin{}
}
next := sort.Search(len(b.sequences), func(i int) bool { return b.sequences[i].start > index })
if next == 0 {
return pdfObjectOrigin{}
}
return b.sequences[next-1].pdfObjectOrigin
}
// combinedOrigin 收集局部合成的来源区段,按首次出现顺序去重
// 入参: ctx 取消上下文
// 返回: pdfObjectOrigin 合并来源, error 取消错误
func (b *pdfObjectBuffer) combinedOrigin(ctx context.Context) (pdfObjectOrigin, error) {
if err := ctx.Err(); err != nil {
return pdfObjectOrigin{}, err
}
if b.len() == 0 {
return pdfObjectOrigin{}, nil
}
origin := b.origin(0)
if len(b.sequences) <= 1 && (len(b.sequences) == 0 || b.sequences[0].start == 0) {
return origin, nil
}
var result pdfOriginSet
result.add(origin.sequence)
for _, sequence := range origin.related {
result.add(sequence)
}
for _, run := range b.sequences {
if err := ctx.Err(); err != nil {
return pdfObjectOrigin{}, err
}
result.add(run.sequence)
for _, sequence := range run.related {
if err := ctx.Err(); err != nil {
return pdfObjectOrigin{}, err
}
result.add(sequence)
}
}
return result.origin, nil
}
// len 获取暂存对象数量,空缓冲返回零
// 返回: int 对象数量
func (b *pdfObjectBuffer) len() int {
if b == nil {
return 0
}
return b.size
}
// truncate 回滚到已有对象位置,清理丢弃对象及片段引用
// 入参: size 保留对象数量
func (b *pdfObjectBuffer) truncate(size int) {
if size < 0 || size > b.len() {
panic("invalid PDF object buffer size")
}
if size == b.len() {
return
}
for len(b.sequences) > 0 && b.sequences[len(b.sequences)-1].start >= size {
last := len(b.sequences) - 1
b.sequences[last] = pdfObjectSequence{}
b.sequences = b.sequences[:last]
}
remaining := size
for index, part := range b.parts {
if remaining <= len(part) {
clear(part[remaining:])
b.parts[index] = part[:remaining]
clear(b.parts[index+1:])
b.parts = b.parts[:index+1]
b.size = size
return
}
remaining -= len(part)
}
}
// data 按绘制顺序整理独立对象数组,取消时保留原片段供重试
// 入参: ctx 取消上下文
// 返回: []GraphicObject 暂存数据, error 取消错误
func (b *pdfObjectBuffer) data(ctx context.Context) ([]GraphicObject, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if b.len() == 0 {
return nil, nil
}
if len(b.parts) == 1 {
return b.parts[0][:b.size:b.size], nil
}
objects := make([]GraphicObject, b.size)
offset := 0
for _, part := range b.parts {
if err := ctx.Err(); err != nil {
return nil, err
}
offset += copy(objects[offset:], part)
}
if err := ctx.Err(); err != nil {
return nil, err
}
b.parts[0] = objects
clear(b.parts[1:])
b.parts = b.parts[:1]
return objects, nil
}
// commitObjects 将当前页面待转换对象写入编辑文档
// 返回: error 对象写入错误
func (p *pdfImporter) commitObjects() error {
if p.objects.len() == 0 {
return nil
}
objects, err := p.objects.data(p.ctx)
if err != nil {
return err
}
objects, err = p.sharedClipObjects(objects)
if err != nil {
return err
}
var bindings []pdfStructureBinding
if p.structureBindings != nil {
bindings, err = p.objects.structureBindings(p.ctx)
if err != nil {
return err
}
}
if err := p.objectValidation().appendOwnedObjects(p.ctx, p.page, objects); err != nil {
return err
}
if p.structureBindings != nil {
p.structureBindings.append(p.editor.pages[p.page].ID, objects, bindings)
}
p.objects = nil
return nil
}