// 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配置转换输出,有损模式按图片实际尺寸降采样,原始及无损模式保持完整采样 // Resources用于导入、局部合成与返回编辑器的共享资源预算 type PDFImportOptions struct { Resources *Resources 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 { resources *Resources 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 取消上下文, input 输入源, options 转换选项 // 返回: *Editor 编辑文档, PDFImportReport 转换统计, error 错误信息 func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Editor, PDFImportReport, error) { if options.Resources == nil { options.Resources, _ = NewResources(ResourceOptions{}) } ctx = options.Resources.context(ctx) if input == nil { return nil, PDFImportReport{}, fmt.Errorf("missing source") } 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{resources: options.Resources}, 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.runtimeResources = options.Resources editor.SetRenderBackends(renderer.Backends()) editor.fontDirs, editor.fontFS = renderer.FontSources() editor.fontSourcesCache = renderer.fontSourcesCache importer := pdfImporter{resources: options.Resources, 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{ctx: ctx, input: input}, input.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, options.Strict); 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 取消上下文, input 输入源, output OFD输出, options 转换选项 // 返回: PDFImportReport 转换统计, error 错误信息 func ConvertPDF(ctx context.Context, input *Input, output io.Writer, options PDFImportOptions) (PDFImportReport, error) { editor, report, err := ImportPDF(ctx, input, 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 }