Files
ofdgo/ofdgo_convert.go
T
2026-10-19 09:43:06 +08:00

599 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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))
}