// 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 ( "archive/zip" "bytes" "context" "errors" "fmt" "io" "runtime" "slices" "strings" "sync" "time" "github.com/xiaoqidun/pdfgo" ) // OutputFormat 描述文档输出格式,Paged表示逐页文件,Raster表示受DPI控制的位图 type OutputFormat struct { Value string `json:"value"` Label string `json:"label"` Extension string `json:"extension"` MIME string `json:"mime"` Paged bool `json:"paged,omitempty"` Raster bool `json:"raster,omitempty"` } // ConvertProgress 回报open、pages、convert、fonts、fonts.*、prepare、write、write.*及export阶段,Total为0表示总量未知 type ConvertProgress struct { Stage string `json:"stage"` Completed int `json:"completed"` Total int `json:"total"` } // ConvertOptions 设置文档转换格式、页码、阅读和渲染选项 // Input为空时识别源格式,Format为输出格式,Pages使用零基索引,nil表示全部页面 // PageRange使用一基页码表达式,与Pages互斥,空字符串表示全部页面 // SkipUnchanged仅在未压缩、源格式与目标相同且未选页时不写出数据 // Pool可由多个独立转换共享,nil时不限制文件间并发 // 整份PDF同格式压缩直接重写PDF对象,其余PDF先转换为OFD对象再输出 // RendererOptions同时用于PDF局部合成和输出,在PDF.RendererOptions之后应用 // Backends统一配置转换与输出,优先于PDF和RendererOptions中的后端设置 // Compression统一配置输出及PDF图片导入,优先于PDF.Compression,文本输出不优化图片 // Fonts仅用于OFD输出,按字体处理全包引用,未处理原因通过报告返回 // PageOutput仅用于逐页格式,同步调用export写出一页并自行处理提交或回滚;此时output可为nil // 未提供PageOutput时,逐页格式打包ZIP;所有回调返回错误均会停止转换 type ConvertOptions struct { Input string Format string Pages []int PageRange string SkipUnchanged bool Pool *ConversionPool Compression CompressionOptions Fonts FontMode Backends *RenderBackends ReaderOptions []ReaderOption RendererOptions []RendererOption PDF PDFImportOptions OnProgress func(ConvertProgress) error PageOutput func(index int, export func(io.Writer) error) error } // ConvertReport 汇总源格式、输出格式、页数、字节数及PDF转换诊断 // Unchanged表示无需转换或原样交付源文件,Bytes仅统计实际输出,不包含调用方额外封装 type ConvertReport struct { Input string `json:"input"` Format OutputFormat `json:"format"` Pages int `json:"pages"` Bytes int64 `json:"bytes"` Unchanged bool `json:"unchanged,omitempty"` PDF PDFImportReport `json:"pdf"` Fonts []FontChange `json:"fonts,omitempty"` } // ConversionPoolOptions 设置共享转换并行度及图像工作集预算 // Parallelism为0时使用GOMAXPROCS,MemoryLimit为0时不指定固定预算 // AvailableMemory返回可用于新增任务的字节数,nil表示不探测,回调须支持并发调用 // 内存预算用于调度,不限制解析器及运行时的实际分配 type ConversionPoolOptions struct { Parallelism int MemoryLimit int64 AvailableMemory func() int64 } // ConversionPool 协调独立转换和图像任务,配置在创建后不变,可跨会话复用 type ConversionPool struct { options ConversionPoolOptions mu sync.Mutex changed chan struct{} active int images int memory int64 } // convertReader 将取消上下文传递到随机读取 type convertReader struct { ctx context.Context input io.ReaderAt } // convertWriter 将取消上下文传递到写入并统计实际字节数 type convertWriter struct { ctx context.Context writer io.Writer count *int64 } // conversionPoolKey 标识单次转换使用的共享预算 type conversionPoolKey struct{} // OutputFormats 返回独立的输出格式列表,不依赖WebUI或具体渲染后端 // 返回: []OutputFormat 输出格式列表 func OutputFormats() []OutputFormat { return []OutputFormat{ {Value: "ofd", Label: "OFD", Extension: "ofd", MIME: "application/ofd"}, {Value: "pdf", Label: "PDF", Extension: "pdf", MIME: "application/pdf"}, {Value: "svg", Label: "SVG", Extension: "svg", MIME: "image/svg+xml", Paged: true}, {Value: "eps", Label: "EPS", Extension: "eps", MIME: "application/postscript", Paged: true}, {Value: "png", Label: "PNG", Extension: "png", MIME: "image/png", Paged: true, Raster: true}, {Value: "jpg", Label: "JPG", Extension: "jpg", MIME: "image/jpeg", Paged: true, Raster: true}, {Value: "txt", Label: "TXT", Extension: "txt", MIME: "text/plain"}, } } // Convert 将PDF或OFD转换为指定格式,不关闭调用方的输入和输出 // 原始模式完整OFD另存保持源字节;选页和跨格式输出重新生成文档,压缩仅作用于输出副本 // 输入按需随机读取,输出分块写出;解析对象及后端可能缓存资源,不保证恒定内存 // 出错时报告保留已有诊断,调用方应丢弃未完成输出;只有返回nil才表示转换成功 // 入参: ctx 取消上下文, input 输入源, output 输出流, options 转换选项 // 返回: ConvertReport 转换报告, error 错误信息 func Convert(ctx context.Context, input *Input, output io.Writer, options ConvertOptions) (report ConvertReport, err error) { if options.Pool != nil { if err = options.Pool.acquire(ctx, false, 0); err != nil { return report, err } defer options.Pool.release(false, 0) ctx = context.WithValue(ctx, conversionPoolKey{}, options.Pool) } if err = options.Compression.Validate(); err != nil { return report, err } if err = ctx.Err(); err != nil { return report, err } format := strings.ToLower(strings.TrimSpace(options.Format)) if format == "jpeg" { format = "jpg" } for _, candidate := range OutputFormats() { if candidate.Value == format { report.Format = candidate break } } if report.Format.Value == "" { return report, fmt.Errorf("unsupported output format %s", options.Format) } if options.Fonts > FontExternal || options.Fonts != FontKeep && format != "ofd" { return report, fmt.Errorf("font processing requires OFD output and a valid mode") } if options.PageOutput != nil && !report.Format.Paged { return report, fmt.Errorf("page output requires a paged format") } if options.Pages != nil && len(options.Pages) == 0 { return report, fmt.Errorf("no pages selected") } if options.Pages != nil && strings.TrimSpace(options.PageRange) != "" { return report, fmt.Errorf("conflicting page selections") } progress := func(stage string, completed, total int) error { if err := ctx.Err(); err != nil { return err } if options.OnProgress != nil { if err := options.OnProgress(ConvertProgress{Stage: stage, Completed: completed, Total: total}); err != nil { return err } } return ctx.Err() } writeProgress := func(stage string, completed, total int) error { if stage == "write" { return progress(stage, completed, total) } return progress("write."+stage, completed, total) } if err = progress("open", 0, 0); err != nil { return report, err } if input == nil || input.Size() <= 0 { return report, fmt.Errorf("empty document input") } size := input.Size() operationInput := &Input{reader: convertReader{ctx: ctx, input: input}, size: size} report.Input = strings.ToLower(strings.TrimSpace(options.Input)) if report.Input == "" { report.Input, err = DetectFormat(operationInput) if err != nil { return report, err } } var reader *Reader var editor *Editor var count int if report.Input != "pdf" && report.Input != "ofd" { return report, fmt.Errorf("unsupported input format") } if options.SkipUnchanged && options.Fonts == FontKeep && options.Compression.Mode == CompressionUnchanged && report.Input == format && options.Pages == nil && strings.TrimSpace(options.PageRange) == "" { report.Unchanged = true return report, nil } if output == nil && options.PageOutput == nil { return report, fmt.Errorf("missing output writer") } if report.Input == "pdf" && format == "pdf" && options.Compression.Mode != CompressionUnchanged && options.Pages == nil && strings.TrimSpace(options.PageRange) == "" { pdfReader, openErr := pdfgo.NewReaderWithOptions(operationInput, size, pdfgo.ReaderOptions{Password: options.PDF.Password, PasswordUTF8: options.PDF.PasswordUTF8}) if openErr != nil { return report, openErr } defer pdfReader.Close() if pageErr := pdfReader.WalkPages(ctx, func(_ int, _ *pdfgo.Page) error { report.Pages++; return nil }); pageErr != nil { return report, pageErr } result, optimizeErr := pdfReader.OptimizeTo(ctx, output, pdfgo.OptimizeOptions{Compression: options.Compression, Creator: ofdCreator, OnProgress: writeProgress}) report.Bytes, report.Unchanged = result.Bytes, result.Preserved return report, optimizeErr } switch report.Input { case "pdf": pdfOptions := options.PDF pdfOptions.Compression = options.Compression if format == "txt" { pdfOptions.Compression = CompressionOptions{} } pdfOptions.RendererOptions = append(append([]RendererOption(nil), pdfOptions.RendererOptions...), options.RendererOptions...) if options.Backends != nil { pdfOptions.Backends = options.Backends } options.RendererOptions = pdfOptions.RendererOptions options.Backends = pdfOptions.Backends pdfOptions.OnProgress = func(stage string, completed, total int) error { if err := progress(stage, completed, total); err != nil { return err } if options.PDF.OnProgress != nil { return options.PDF.OnProgress(stage, completed, total) } return nil } editor, report.PDF, err = ImportPDF(ctx, operationInput, pdfOptions) if err != nil { return report, err } count = editor.PageCount() case "ofd": reader, err = NewReader(operationInput, options.ReaderOptions...) if err != nil { return report, err } defer reader.Close() count, err = reader.PageCount() if err != nil { return report, err } } if strings.TrimSpace(options.PageRange) != "" { options.Pages, err = ParsePageRange(options.PageRange, count) if err != nil { return report, err } } indices, err := exportPageIndices(count, options.Pages) if err != nil { return report, err } report.Pages = len(indices) writer := convertWriter{ctx: ctx, writer: output, count: &report.Bytes} if format == "ofd" { if options.Pages == nil && editor == nil && options.Fonts == FontKeep && options.Compression.Mode == CompressionUnchanged { if err = progress("write", 0, 0); err != nil { return report, err } _, err = io.Copy(writer, io.NewSectionReader(operationInput, 0, size)) report.Unchanged = err == nil return report, err } if editor == nil { editor, err = reader.Editor() if err != nil { return report, err } } editor.OnWriteProgress = writeProgress if options.Fonts != FontKeep { if err = progress("fonts", 0, 0); err != nil { return report, err } configuration := NewRenderer(&Reader{}, options.RendererOptions...) if options.Backends != nil { WithRenderBackends(*options.Backends)(configuration) } editor.SetRenderBackends(configuration.Backends()) editor.SetFontDirs(configuration.fontDirs...) editor.SetFontFS(configuration.fontFS...) report.Fonts, err = editor.ProcessFontsWithOptions(ctx, FontProcessOptions{Mode: options.Fonts, OnProgress: func(stage string, completed, total int) error { if stage == "fonts" { return progress(stage, completed, total) } return progress("fonts."+stage, completed, total) }}) if err != nil { return report, err } if report.Input == "ofd" && options.Pages == nil && options.Compression.Mode == CompressionUnchanged && !slices.ContainsFunc(report.Fonts, func(change FontChange) bool { return change.Changed }) { if err = progress("write", 0, 0); err != nil { return report, err } _, err = io.Copy(writer, io.NewSectionReader(operationInput, 0, size)) report.Unchanged = err == nil return report, err } } if options.Pages == nil { _, err = editor.WriteToWithOptions(ctx, writer, WriteOptions{Compression: options.Compression}) } else { _, err = editor.WritePagesTo(ctx, writer, indices, WriteOptions{Compression: options.Compression}) } return report, err } if reader == nil { if err = progress("prepare", 0, count); err != nil { return report, err } reader, err = editor.reader(progress) if err != nil { return report, err } defer reader.Close() } renderer := NewRenderer(reader, options.RendererOptions...) renderer.Compression = options.Compression renderer.OutputContext = ctx if options.Backends != nil { WithRenderBackends(*options.Backends)(renderer) } renderer.OnExportProgress = func(completed, total int) error { return progress("export", completed, total) } switch format { case "pdf": err = renderer.RenderToMultiPagePDF(writer, indices...) case "txt": err = renderer.RenderToMultiPageText(writer, indices...) default: if options.PageOutput == nil { err = renderer.RenderToZIP(writer, format, indices...) report.Format = OutputFormat{Value: "zip", Label: "ZIP", Extension: "zip", MIME: "application/zip"} break } for i, index := range indices { if err = progress("export", i, len(indices)); err != nil { return report, err } page, readErr := reader.PageContentByIndex(index) if readErr != nil { return report, readErr } if err = options.PageOutput(index, func(output io.Writer) error { return renderer.RenderTo(page, convertWriter{ctx: ctx, writer: output, count: &report.Bytes}, format) }); err != nil { return report, fmt.Errorf("export page %d: %w", index+1, err) } } err = progress("export", len(indices), len(indices)) } return report, err } // ReadAt 读取前后响应取消,保留读取字节数和源错误 // 入参: data 目标缓冲, offset 源偏移 // 返回: int 读取字节数, error 读取错误 func (r convertReader) ReadAt(data []byte, offset int64) (int, error) { if err := r.ctx.Err(); err != nil { return 0, err } n, err := r.input.ReadAt(data, offset) if err == nil { err = r.ctx.Err() } return n, err } // Write 写入前后响应取消,保留写入及短写错误 // 入参: data 待写数据 // 返回: int 写入字节数, error 写入错误 func (w convertWriter) Write(data []byte) (int, error) { if err := w.ctx.Err(); err != nil { return 0, err } n, err := w.writer.Write(data) *w.count += int64(n) if err == nil && n != len(data) { err = io.ErrShortWrite } if err == nil { err = w.ctx.Err() } return n, err } // WriteString 写入字符串并保留底层原生写入能力,前后响应取消 // 入参: data 待写文本 // 返回: int 写入字节数, error 写入、短写或取消错误 func (w convertWriter) WriteString(data string) (int, error) { if err := w.ctx.Err(); err != nil { return 0, err } n, err := io.WriteString(w.writer, data) *w.count += int64(n) if err == nil && n != len(data) { err = io.ErrShortWrite } if err == nil { err = w.ctx.Err() } return n, err } // DetectFormat 按文件内容识别PDF或OFD,不读取压缩内容或关闭输入 // 识别结果不代表文档完整有效,仍需通过对应阅读器解析 // 入参: input 输入源 // 返回: string 格式标识pdf或ofd, error 读取错误或不支持的格式 func DetectFormat(input *Input) (string, error) { if input == nil || input.Size() <= 0 { return "", fmt.Errorf("empty document input") } size := input.Size() var header [1024]byte data := header[:min(size, int64(len(header)))] n, err := input.ReadAt(data, 0) if err != nil && err != io.EOF { return "", err } if n != len(data) { return "", io.ErrUnexpectedEOF } if bytes.HasPrefix(data, []byte("%PDF-")) { return "pdf", nil } archive, err := zip.NewReader(input, size) if err == nil { reader := &Reader{Zip: archive} if err := reader.indexPackage(); err != nil { return "", err } if reader.fileNames["OFD.xml"] || reader.fileNames["Encryptions.xml"] { return "ofd", nil } } else { if !errors.Is(err, zip.ErrFormat) { return "", err } if bytes.Contains(data, []byte("%PDF-")) { return "pdf", nil } } return "", fmt.Errorf("unsupported input format") } // NewConversionPool 创建共享转换调度器,不启动后台任务 // 入参: options 并行及内存选项 // 返回: *ConversionPool 调度器, error 配置错误 func NewConversionPool(options ConversionPoolOptions) (*ConversionPool, error) { if options.Parallelism < 0 || options.MemoryLimit < 0 { return nil, fmt.Errorf("invalid conversion pool options") } if options.Parallelism == 0 { options.Parallelism = runtime.GOMAXPROCS(0) } return &ConversionPool{options: options, changed: make(chan struct{})}, nil } // Parallelism 返回同时运行的转换数量上限 // 返回: int 并行上限 func (p *ConversionPool) Parallelism() int { return p.options.Parallelism } // memoryLimit 读取当前可分配的图像工作量预算 // 返回: int64 可用字节数,负值表示未设置预算 func (p *ConversionPool) memoryLimit() int64 { limit := int64(-1) if p.options.MemoryLimit > 0 { limit = p.options.MemoryLimit } if p.options.AvailableMemory != nil { available := max(0, p.options.AvailableMemory()) if limit < 0 || available < limit { limit = available } } return limit } // acquire 等待转换或图像预算,超预算图像可独占运行 // 入参: ctx 取消上下文, image 是否为图像任务, size 图像工作量 // 返回: error 取消错误 func (p *ConversionPool) acquire(ctx context.Context, image bool, size int64) error { var timer *time.Timer defer func() { if timer != nil { timer.Stop() } }() for { if err := ctx.Err(); err != nil { return err } limit := p.memoryLimit() p.mu.Lock() allowed := p.active < p.options.Parallelism && (p.active == 0 || limit != 0) if image { allowed = p.images < runtime.GOMAXPROCS(0) && (p.images == 0 || limit < 0 || size <= limit-p.memory) } if allowed { if image { p.images++ p.memory += size } else { p.active++ } p.mu.Unlock() return nil } changed := p.changed p.mu.Unlock() var tick <-chan time.Time if p.options.AvailableMemory != nil { if timer == nil { timer = time.NewTimer(250 * time.Millisecond) } else { timer.Reset(250 * time.Millisecond) } tick = timer.C } select { case <-ctx.Done(): case <-changed: case <-tick: } if timer != nil { timer.Stop() } } } // release 归还任务预算并唤醒等待者 // 入参: image 是否为图像任务, size 图像工作量 func (p *ConversionPool) release(image bool, size int64) { p.mu.Lock() defer p.mu.Unlock() if image { p.images-- p.memory -= size } else { p.active-- } close(p.changed) p.changed = make(chan struct{}) } // conversionWorkers 按当前文件数量分配内部计算任务 // 入参: ctx 转换上下文, fallback 非共享模式并行上限 // 返回: int 内部并行数 func conversionWorkers(ctx context.Context, fallback int) int { p, _ := ctx.Value(conversionPoolKey{}).(*ConversionPool) if p == nil { return min(fallback, runtime.GOMAXPROCS(0)) } p.mu.Lock() active := p.active p.mu.Unlock() return max(1, runtime.GOMAXPROCS(0)/max(1, active)) }