feat(强化加载): 支持PDF按页加载与增量转换
build.yaml / build (push) Successful in 44s

This commit is contained in:
xiaoqidun committed 2026-10-26 17:21:15 +08:00
1 parent 04d7d2d672
commit fb4f03465b
11 files changed
+647 -154

No files matched your search

+245 -39
View File
@@ -32,6 +32,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/xiaoqidun/ofdgo"
@@ -435,15 +436,30 @@ type outputContext struct {
cancel context.CancelFunc
checkpoint func() error
checked time.Time
pending uint8
checks atomic.Uint32
interval uint8
}
// pendingPDF 保存当前标签的增量转换、输入及操作回调
type pendingPDF struct {
importer *ofdgo.PDFImport
retry bool
input *ofdgo.Input
page int
next int
progress platformValue
font platformValue
operation *outputContext
report func(string, int, int) error
}
// businessEngine 独立文档、编辑、剪贴板及操作上下文
type businessEngine struct {
resources *ofdgo.Resources
currentSession *Session
currentEditor *ofdgo.Editor
pdf *pendingPDF
pdfWarnings []byte
saveSnapshot *ofdgo.Editor
printDocument *ofdgo.PrintDocument
pendingImport *ofdgo.Reader
@@ -1394,58 +1410,243 @@ func (e *businessEngine) detectDocumentFormat(args []platformValue) (any, error)
return ofdgo.DetectFormat(input)
}
// convertPDFDocument 通过库转换PDF,不替换当前文档会话
// 入参: args PDF数据、外部字体、UTF-8密码、字体目录、进度及字体读取回调
// 返回: any OFD数据及转换警告, error 错误信息
func (e *businessEngine) convertPDFDocument(args []platformValue) (any, error) {
if len(args) != 6 {
return nil, fmt.Errorf("missing PDF data")
// clearPDF 释放当前标签的未完成转换和输入
func (e *businessEngine) clearPDF() {
e.pdfWarnings = nil
if e.pdf == nil {
return
}
e.pdf.importer.Close()
e.pdf.input.Close()
e.pdf = nil
}
// pdfContext 更新当前操作的取消、进度和按需字体读取回调
// 入参: args 进度及字体回调
// 返回: any 是否仍有待转换内容, error 参数错误
func (e *businessEngine) pdfContext(args []platformValue) (any, error) {
if len(args) != 2 {
return nil, fmt.Errorf("missing PDF callbacks")
}
if e.pdf != nil {
e.pdf.progress, e.pdf.font = args[0], args[1]
}
return e.pdf != nil, nil
}
// openPDF 建立PDF索引,当前页转换由阅读请求触发
// 入参: args 输入、字体、密码、注解设置、字体目录、进度及字体回调
// 返回: any 文档索引, error 打开错误
func (e *businessEngine) openPDF(args []platformValue) (any, error) {
if len(args) != 7 {
return nil, fmt.Errorf("missing PDF input")
}
input, err := e.inputFromValue(args[0])
if err != nil {
return nil, err
}
defer input.Close()
pending := &pendingPDF{input: input, page: -1, progress: args[5], font: args[6]}
keep := false
var password []byte
defer func() { clear(password) }()
defer func() {
if !keep {
input.Close()
if pending.importer != nil {
pending.importer.Close()
}
}
}()
fonts, err := e.fontsFromJS(args[1])
if err != nil {
return nil, err
}
var output bytes.Buffer
options := ofdgo.PDFImportOptions{Resources: e.resources, OnProgress: e.editorOperationProgress(args[4]), PasswordUTF8: true}
if !args[2].IsNull() && !args[2].IsUndefined() {
options.Password, err = e.bytesFromJS(args[2])
password, err = e.bytesFromJS(args[2])
if err != nil {
return nil, err
}
defer clear(options.Password)
password = slices.Clone(password)
}
fontSources := []fs.FS{ofdgo.NewFontFS(fonts)}
if names := e.stringsFromJS(args[3]); len(names) > 0 {
fontSources = append(fontSources, ofdgo.NewLazyFontFS(names, func(name string) ([]byte, error) {
return e.loadConversionFont(args[5], name)
pending.operation, pending.report = e.newEditorOperationContext(pending.progress)
options := ofdgo.PDFImportOptions{Resources: e.resources, PasswordUTF8: true, Password: password,
OnProgress: func(stage string, completed, total int) error {
return pending.report(stage, completed, total)
},
}
sources := []fs.FS{ofdgo.NewFontFS(fonts)}
if names := e.stringsFromJS(args[4]); len(names) != 0 {
sources = append(sources, ofdgo.NewLazyFontFS(names, func(name string) ([]byte, error) {
return e.loadConversionFont(pending.font, name)
}))
}
options.RendererOptions = []ofdgo.RendererOption{ofdgo.WithFontFS(fontSources...)}
editor, report, err := ofdgo.ImportPDF(e.operationContext(), input, options)
options.RendererOptions = []ofdgo.RendererOption{ofdgo.WithFontFS(sources...)}
pending.importer, err = ofdgo.NewPDFImport(pending.operation, input, options)
if err != nil {
return nil, err
}
if len(options.Password) != 0 {
if err := editor.SetEncryption(&ofdgo.EncryptionOptions{Password: options.Password}); err != nil {
if len(password) != 0 {
if err := pending.importer.SetEncryption(&ofdgo.EncryptionOptions{Password: password}); err != nil {
return nil, err
}
}
editor.OnWriteProgress = func(stage string, completed, total int) error {
return options.OnProgress("write."+stage, completed, total)
}
if _, err := editor.WriteTo(&output); err != nil {
return nil, err
}
warnings, err := json.Marshal(report.Warnings)
reader, err := pending.importer.Reader(pending.operation, -1)
if err != nil {
return nil, err
}
return successResult(map[string]any{"bytes": bytesToJS(output.Bytes()), "warnings": string(warnings)}), nil
session, err := newSession(reader, OpenOptions{Fonts: fonts, RenderAnnotations: args[3].Bool()})
if err != nil {
return nil, err
}
e.clearPDF()
e.clearImport()
e.currentSession.Close()
e.currentSession, e.currentEditor = session, nil
e.copiedObjects, e.copiedStyle = nil, nil
e.pdf = pending
keep = true
return session.Summary(), nil
}
// pdfError 记录需要重建的取消状态,后续请求可继续使用原文档
// 入参: err 转换错误
// 返回: error 原错误
func (e *businessEngine) pdfError(err error) error {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
e.pdf.retry = true
}
return err
}
// replacePDFReader 切换独立阅读快照,保留当前字体和注解设置
// 入参: reader 已生成的阅读器
// 返回: error 会话创建错误
func (e *businessEngine) replacePDFReader(reader *ofdgo.Reader) error {
previous := e.currentSession
next, err := newSession(reader, OpenOptions{RenderAnnotations: previous.Renderer.RenderAnnotations})
if err != nil {
return err
}
dirs, sources := previous.Renderer.FontSources()
ofdgo.WithFontDirs(dirs...)(next.Renderer)
ofdgo.WithFontFS(sources...)(next.Renderer)
ofdgo.WithRenderBackends(previous.Renderer.Backends())(next.Renderer)
next.Renderer.OutputContext = e.pdf.operation
next.fontFS = previous.fontFS
next.svgFonts = previous.svgFonts
e.currentSession = next
return previous.Close()
}
// finishPDF 补全转换后直接创建阅读快照,不生成中间压缩包
// 返回: []byte 转换警告JSON, error 转换错误
func (e *businessEngine) finishPDF() ([]byte, error) {
if e.pdf == nil {
return e.pdfWarnings, nil
}
editor, report, err := e.pdf.importer.Finish(e.pdf.operation)
if err != nil {
return nil, e.pdfError(err)
}
reader, err := editor.Reader(e.pdf.operation)
if err != nil {
return nil, err
}
if err := e.replacePDFReader(reader); err != nil {
return nil, err
}
warnings, err := json.Marshal(report.Warnings)
e.clearPDF()
e.pdfWarnings = warnings
return warnings, err
}
// loadPDF 按时间片补全未转换页面,全部完成后交付完整会话
// 入参: args 空参数
// 返回: any 完成标识、文档信息及警告, error 转换错误
func (e *businessEngine) loadPDF(args []platformValue) (any, error) {
if e.currentSession == nil {
return nil, fmt.Errorf("ofd document is not opened")
}
if e.pdf != nil {
deadline := time.Now().Add(32 * time.Millisecond)
for e.pdf.next < e.pdf.importer.PageCount() {
index := e.pdf.next
if !e.pdf.importer.Ready(index) {
if err := e.pdf.importer.ConvertPage(e.pdf.operation, index); err != nil {
return nil, e.pdfError(err)
}
}
e.pdf.next++
if time.Now().After(deadline) {
return map[string]any{"done": false}, nil
}
}
}
warnings, err := e.finishPDF()
if err != nil {
return nil, err
}
return map[string]any{"done": true, "doc": e.currentSession.Summary(), "warnings": string(warnings)}, nil
}
// preparePDF 按命令准备页面快照或完整文档,不将未完成内容交给编辑和输出
// 入参: name 命令名称, args 命令参数
// 返回: error 转换或取消错误
func (e *businessEngine) preparePDF(name string, args []platformValue) error {
if e.pdf == nil {
return nil
}
if name == "ofdgoPDFContext" || name == "ofdgoOpenPDF" || name == "ofdgoOpen" {
return nil
}
e.pdf.operation, e.pdf.report = e.newEditorOperationContext(e.pdf.progress)
e.pdf.operation.checked = time.Now()
e.pdf.operation.interval = 255
e.currentSession.Renderer.OutputContext = e.pdf.operation
if e.pdf.retry {
if err := e.pdf.importer.Reset(e.pdf.operation); err != nil {
return err
}
e.pdf.page, e.pdf.next, e.pdf.retry = -1, 0, false
reader, err := e.pdf.importer.Reader(e.pdf.operation, -1)
if err != nil {
return err
}
if err := e.replacePDFReader(reader); err != nil {
return err
}
}
switch name {
case "ofdgoLoadPDF", "ofdgoConfigure", "ofdgoDocumentInfo", "ofdgoSVGFontData", "ofdgoRenderBackends", "ofdgoOutputFormats", "ofdgoExportFormats", "ofdgoParsePageRange", "ofdgoMatchFontFiles", "ofdgoOutlineLinks", "ofdgoDocumentNavigation", "ofdgoInvoices":
return nil
case "ofdgoOpenLinks":
if len(args) == 0 {
return nil
}
fallthrough
case "ofdgoRenderPage", "ofdgoSearchPage", "ofdgoPageTextString":
if len(args) == 0 {
return fmt.Errorf("missing page index")
}
index := args[0].Int()
if e.pdf.page == index {
return nil
}
reader, err := e.pdf.importer.Reader(e.pdf.operation, index)
if err != nil {
return e.pdfError(err)
}
if err := e.replacePDFReader(reader); err != nil {
return err
}
e.pdf.page = index
return nil
default:
_, err := e.finishPDF()
return err
}
}
// convertFile 使用库转换独立文件,不读取或修改当前阅读和编辑会话
@@ -1641,6 +1842,7 @@ func (e *businessEngine) openDocument(args []platformValue) (any, error) {
if err != nil {
return nil, err
}
e.clearPDF()
e.clearImport()
e.currentEditor = nil
e.copiedObjects = nil
@@ -1689,6 +1891,9 @@ func (e *businessEngine) configureDocument(args []platformValue) (any, error) {
// 入参: args 浏览器参数
// 返回: any 文档信息, error 错误信息
func (e *businessEngine) documentInfo(args []platformValue) (any, error) {
if e.pdf != nil {
return map[string]any{"detailsPending": true}, nil
}
if e.currentSession == nil {
return nil, fmt.Errorf("ofd document is not opened")
}
@@ -7418,8 +7623,8 @@ func (e *businessEngine) newOutputContext(checkpoint platformValue) *outputConte
// newEditorOperationContext 在底层取消检查时保持最近的进度,避免版本处理期间界面无响应
// 入参: callback 浏览器进度及取消回调
// 返回: context.Context 操作上下文, func(string, int, int) error 阶段进度回调
func (e *businessEngine) newEditorOperationContext(callback platformValue) (context.Context, func(string, int, int) error) {
// 返回: *outputContext 操作上下文, func(string, int, int) error 阶段进度回调
func (e *businessEngine) newEditorOperationContext(callback platformValue) (*outputContext, func(string, int, int) error) {
ctx, cancel := context.WithCancel(e.operationContext())
stage, completed, total := "ids", 0, 0
progress := e.editorOperationProgress(callback)
@@ -7436,16 +7641,15 @@ func (c *outputContext) Err() error {
if err := c.Context.Err(); err != nil {
return err
}
c.mu.Lock()
defer c.mu.Unlock()
if c.pending != 0 {
c.pending--
interval := uint32(c.interval) + 1
if c.interval == 0 {
interval = 16
}
if (c.checks.Add(1)-1)%interval != 0 {
return nil
}
c.pending = 15
if c.interval != 0 {
c.pending = c.interval
}
c.mu.Lock()
defer c.mu.Unlock()
if time.Since(c.checked) >= 50*time.Millisecond {
c.checked = time.Now()
if err := c.checkpoint(); err != nil {
@@ -7465,6 +7669,9 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an
"ofdgoDiscardPrint": e.discardPrint,
"ofdgoDetectFormat": e.detectDocumentFormat,
"ofdgoOpen": e.openDocument,
"ofdgoOpenPDF": e.openPDF,
"ofdgoLoadPDF": e.loadPDF,
"ofdgoPDFContext": e.pdfContext,
"ofdgoSelectDocument": e.selectDocument,
"ofdgoDocumentVersions": e.documentVersions,
"ofdgoExportVersion": e.exportVersion,
@@ -7488,7 +7695,6 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an
"ofdgoChangeVersion": e.changeDocumentVersion,
"ofdgoChangeDocument": e.changePackageDocument,
"ofdgoPackageDocuments": e.packageDocuments,
"ofdgoConvertPDF": e.convertPDFDocument,
"ofdgoConvertFile": e.convertFile,
"ofdgoPackFiles": e.packFiles,
"ofdgoOutputFormats": func([]platformValue) (any, error) { return ofdgo.OutputFormats(), nil },
+10 -2
View File
@@ -191,7 +191,7 @@ func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit f
if e.closed {
return nativeFailure(fmt.Errorf("native engine is closed"))
}
if name == "ofdgoRenderPage" && e.currentSession != nil {
if name == "ofdgoRenderPage" && e.currentSession != nil && e.pdf == nil {
defer func() {
if failure := recover(); failure != nil {
result = nativeFailure(fmt.Errorf("%v", failure))
@@ -312,6 +312,10 @@ func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit f
return nativeFailure(fmt.Errorf("unknown operation: %s", name))
}
call := &nativeCall{engine: e, ctx: ctx, emit: emit}
if e.pdf != nil {
e.pdf.progress = platformValueOf(call.progress("conversion", "", true))
e.pdf.font = platformValueOf(nativeCallback(call.font))
}
defer func() {
if err := call.abort(); err != nil {
if result == nil || result["ok"] == true {
@@ -329,6 +333,9 @@ func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit f
for i, arg := range prepared {
values[i] = platformValueOf(arg)
}
if err := e.preparePDF(name, values); err != nil {
return nativeFailure(err)
}
data, err := e.safeCall(fn, values)
if err != nil {
return nativeFailure(err)
@@ -357,6 +364,7 @@ func (e *NativeEngine) Close() error {
e.gate <- struct{}{}
defer func() { <-e.gate }()
e.closed = true
e.clearPDF()
_, _ = e.discardPrint(nil)
_ = e.waitRenders(context.Background())
e.renderIdle = nil
@@ -955,7 +963,7 @@ func (c *nativeCall) prepare(name string, args []any) ([]any, error) {
return append(args, checkpoint), nil
case "ofdgoImportPages", "ofdgoImportDocuments", "ofdgoImportImages":
return append(args, c.progress("import", "", true)), nil
case "ofdgoConvertPDF":
case "ofdgoOpenPDF":
return append(args, c.progress("conversion", "", true), nativeCallback(c.font)), nil
case "ofdgoExportImages", "ofdgoExportPage", "ofdgoExportDocument", "ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion", "ofdgoSaveDocument", "ofdgoSaveSigned":
if len(args) == 0 {
+11
View File
@@ -79,10 +79,20 @@ func (w *wasmEngines) call(args []js.Value) (any, error) {
if e == nil {
return nil, fmt.Errorf("unknown engine: %s", args[0].String())
}
if e.pdf != nil {
defer func() {
if e.currentSession != nil {
e.currentSession.Renderer.OutputContext = context.Background()
}
}()
}
fn := e.businessCommands()[args[1].String()]
if fn == nil {
return nil, fmt.Errorf("unknown operation: %s", args[1].String())
}
if err := e.preparePDF(args[1].String(), args[2:]); err != nil {
return nil, err
}
return fn(args[2:])
}
@@ -100,6 +110,7 @@ func (w *wasmEngines) close(args []js.Value) (any, error) {
}
delete(w.engines, id)
_, _ = e.discardPrint(nil)
e.clearPDF()
e.clearImport()
err := e.currentSession.Close()
*e = businessEngine{}