feat(打印能力): 新增打印并优化界面布局
build.yaml / build (push) Successful in 46s

This commit is contained in:
xiaoqidun committed 2026-10-24 20:53:46 +08:00
1 parent 799041171b
commit 0ae9ad6562
22 files changed
+2220 -21

No files matched your search

+375
View File
@@ -0,0 +1,375 @@
// 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 pdf
import (
"bytes"
"compress/zlib"
"context"
"encoding/hex"
"fmt"
"image"
"io"
"maps"
"math"
"slices"
"strconv"
"strings"
"github.com/xiaoqidun/pdfgo"
)
// PrintPlacement 保存源PDF页索引及毫米纸面上的变换和裁剪
type PrintPlacement struct {
Page int
Matrix [6]float64
Clip [4]float64
}
// PrintSheet 保存一个纸面的物理尺寸、放置内容和辅助线
type PrintSheet struct {
Width, Height float64
Placements []PrintPlacement
Lines [][4]float64
Label string
LabelX, LabelY float64
}
// printPDFObjects 写出自有打印对象,原PDF资源仅复制实际引用的部分
type printPDFObjects struct {
ctx context.Context
objects []pdfgo.Object
copied map[pdfgo.Reference]pdfgo.Reference
source *pdfgo.Reader
}
// printPDFNumbers 编码内容流数值,不使用PDF语法不接受的科学记数法
// 入参: out 内容流缓冲区, values 待编码数值
func printPDFNumbers(out *bytes.Buffer, values ...float64) {
for _, value := range values {
out.WriteString(strconv.FormatFloat(value, 'f', -1, 64))
out.WriteByte(' ')
}
}
// add 登记一个独立输出对象
// 入参: object 待登记对象
// 返回: pdfgo.Reference 输出对象引用
func (p *printPDFObjects) add(object pdfgo.Object) pdfgo.Reference {
p.objects = append(p.objects, object)
return pdfgo.Reference{Number: int64(len(p.objects))}
}
// copy 递归复制资源并改写引用,保留流编码及资源共享
// 入参: object 待复制对象, depth 当前递归深度
// 返回: pdfgo.Object 改写引用后的对象, error 资源、嵌套或取消错误
func (p *printPDFObjects) copy(object pdfgo.Object, depth int) (pdfgo.Object, error) {
if err := p.ctx.Err(); err != nil {
return nil, err
}
if depth > 256 {
return nil, fmt.Errorf("print PDF resource nesting too deep")
}
switch value := object.(type) {
case pdfgo.Reference:
if next, ok := p.copied[value]; ok {
return next, nil
}
next := p.add(nil)
p.copied[value] = next
resolved, err := p.source.Object(value)
if err != nil {
return nil, err
}
copied, err := p.copy(resolved, depth+1)
if err != nil {
return nil, err
}
p.objects[next.Number-1] = copied
return next, nil
case pdfgo.Dictionary:
result := make(pdfgo.Dictionary, len(value))
for key, item := range value {
copied, err := p.copy(item, depth+1)
if err != nil {
return nil, err
}
result[key] = copied
}
return result, nil
case pdfgo.Array:
result := make(pdfgo.Array, len(value))
for i, item := range value {
copied, err := p.copy(item, depth+1)
if err != nil {
return nil, err
}
result[i] = copied
}
return result, nil
case *pdfgo.Stream:
dict := maps.Clone(value.Dictionary)
delete(dict, "Length")
copied, err := p.copy(dict, depth+1)
if err != nil {
return nil, err
}
return &pdfgo.Stream{Dictionary: copied.(pdfgo.Dictionary), Data: value.Data}, nil
default:
return object, nil
}
}
// WritePrintPDF 将源页面作为独立表单放置,保持矢量、文字、资源及透明组
// 入参: ctx 输出上下文, writer 输出流, source 源PDF数据, sheets 有序纸面
// 返回: error 资源、编码、写入或取消错误
func WritePrintPDF(ctx context.Context, writer io.Writer, source []byte, sheets []PrintSheet) error {
var reader *pdfgo.Reader
if len(source) > 0 {
var err error
reader, err = pdfgo.NewReader(bytes.NewReader(source), int64(len(source)))
if err != nil {
return err
}
defer reader.Close()
}
p := &printPDFObjects{ctx: ctx, objects: make([]pdfgo.Object, 2), copied: make(map[pdfgo.Reference]pdfgo.Reference), source: reader}
var pages []*pdfgo.Page
if reader != nil {
if err := reader.WalkPages(ctx, func(_ int, page *pdfgo.Page) error { pages = append(pages, page); return nil }); err != nil {
return err
}
}
forms := make(map[int]pdfgo.Reference)
var children pdfgo.Array
const pt = 72.0 / 25.4
for _, sheet := range sheets {
if err := ctx.Err(); err != nil {
return err
}
var content bytes.Buffer
objects := make(pdfgo.Dictionary)
for _, placement := range sheet.Placements {
if placement.Page < 0 || placement.Page >= len(pages) {
return fmt.Errorf("print PDF page outside source")
}
page := pages[placement.Page]
if page.Rotation != 0 || page.UserUnit != 1 {
return fmt.Errorf("print PDF backend must normalize page rotation and units")
}
form, ok := forms[placement.Page]
if !ok {
resources, err := p.copy(page.Resources, 0)
if err != nil {
return err
}
var data bytes.Buffer
if _, err := page.WriteContent(ctx, &data); err != nil {
return err
}
box := page.MediaBox
dict := pdfgo.Dictionary{"Type": pdfgo.Name("XObject"), "Subtype": pdfgo.Name("Form"), "FormType": pdfgo.Integer(1), "BBox": pdfgo.Array{pdfgo.Real(box.XMin), pdfgo.Real(box.YMin), pdfgo.Real(box.XMax), pdfgo.Real(box.YMax)}, "Resources": resources}
if group := page.Dictionary["Group"]; group != nil {
copied, err := p.copy(group, 0)
if err != nil {
return err
}
dict["Group"] = copied
}
form = p.add(&pdfgo.Stream{Dictionary: dict, Data: data.Bytes()})
forms[placement.Page] = form
}
name := pdfgo.Name(fmt.Sprintf("P%d", placement.Page))
objects[name] = form
c, m := placement.Clip, placement.Matrix
hs := (page.MediaBox.YMax - page.MediaBox.YMin) / pt
x0, y0 := page.MediaBox.XMin, page.MediaBox.YMin
content.WriteString("q ")
printPDFNumbers(&content, c[0]*pt, (sheet.Height-c[1]-c[3])*pt, c[2]*pt, c[3]*pt)
content.WriteString("re W n ")
printPDFNumbers(&content, m[0], -m[1], -m[2], m[3], (m[2]*hs+m[4])*pt-m[0]*x0+m[2]*y0, (sheet.Height-m[3]*hs-m[5])*pt+m[1]*x0-m[3]*y0)
fmt.Fprintf(&content, "cm /%s Do Q\n", name)
}
for _, line := range sheet.Lines {
content.WriteString("q 0 G ")
printPDFNumbers(&content, 0.15*pt)
content.WriteString("w ")
printPDFNumbers(&content, line[0]*pt, (sheet.Height-line[1])*pt)
content.WriteString("m ")
printPDFNumbers(&content, line[2]*pt, (sheet.Height-line[3])*pt)
content.WriteString("l S Q\n")
}
resources := pdfgo.Dictionary{"XObject": objects}
if sheet.Label != "" {
content.WriteString("q 0 g BT /Label 7.94 Tf 1 0 0 1 ")
printPDFNumbers(&content, sheet.LabelX*pt, (sheet.Height-sheet.LabelY)*pt)
content.WriteString("Tm ")
if err := printPDFObject(&content, pdfgo.String(sheet.Label), 0); err != nil {
return err
}
content.WriteString(" Tj ET Q\n")
resources["Font"] = pdfgo.Dictionary{"Label": pdfgo.Dictionary{"Type": pdfgo.Name("Font"), "Subtype": pdfgo.Name("Type1"), "BaseFont": pdfgo.Name("Helvetica")}}
}
stream := p.add(&pdfgo.Stream{Dictionary: pdfgo.Dictionary{}, Data: content.Bytes()})
children = append(children, p.add(pdfgo.Dictionary{"Type": pdfgo.Name("Page"), "Parent": pdfgo.Reference{Number: 2}, "MediaBox": pdfgo.Array{pdfgo.Integer(0), pdfgo.Integer(0), pdfgo.Real(sheet.Width * pt), pdfgo.Real(sheet.Height * pt)}, "Resources": resources, "Contents": stream}))
}
return p.write(writer, children)
}
// WritePrintImages 逐纸面消费光栅结果,不在输出对象中保留未压缩像素
// 入参: ctx 输出上下文, writer 输出流, sheets 有序纸面, render 按索引渲染纸面
// 返回: error 渲染、编码、写入或取消错误
func WritePrintImages(ctx context.Context, writer io.Writer, sheets []PrintSheet, render func(int) (image.Image, error)) error {
p := &printPDFObjects{ctx: ctx, objects: make([]pdfgo.Object, 2)}
var children pdfgo.Array
const pt = 72.0 / 25.4
for i, sheet := range sheets {
if err := ctx.Err(); err != nil {
return err
}
img, err := render(i)
if err != nil {
return err
}
var data bytes.Buffer
compressed := zlib.NewWriter(&data)
b := img.Bounds()
row := make([]byte, b.Dx()*3)
for y := b.Min.Y; y < b.Max.Y; y++ {
if err := ctx.Err(); err != nil {
return err
}
for x := b.Min.X; x < b.Max.X; x++ {
r, g, blue, _ := img.At(x, y).RGBA()
offset := (x - b.Min.X) * 3
row[offset], row[offset+1], row[offset+2] = byte(r>>8), byte(g>>8), byte(blue>>8)
}
if _, err := compressed.Write(row); err != nil {
return err
}
}
if err := compressed.Close(); err != nil {
return err
}
picture := p.add(&pdfgo.Stream{Dictionary: pdfgo.Dictionary{"Type": pdfgo.Name("XObject"), "Subtype": pdfgo.Name("Image"), "Width": pdfgo.Integer(b.Dx()), "Height": pdfgo.Integer(b.Dy()), "ColorSpace": pdfgo.Name("DeviceRGB"), "BitsPerComponent": pdfgo.Integer(8), "Filter": pdfgo.Name("FlateDecode")}, Data: data.Bytes()})
var content bytes.Buffer
content.WriteString("q ")
printPDFNumbers(&content, sheet.Width*pt, 0, 0, sheet.Height*pt, 0, 0)
content.WriteString("cm /I Do Q\n")
stream := p.add(&pdfgo.Stream{Dictionary: pdfgo.Dictionary{}, Data: content.Bytes()})
children = append(children, p.add(pdfgo.Dictionary{"Type": pdfgo.Name("Page"), "Parent": pdfgo.Reference{Number: 2}, "MediaBox": pdfgo.Array{pdfgo.Integer(0), pdfgo.Integer(0), pdfgo.Real(sheet.Width * pt), pdfgo.Real(sheet.Height * pt)}, "Resources": pdfgo.Dictionary{"XObject": pdfgo.Dictionary{"I": picture}}, "Contents": stream}))
}
return p.write(writer, children)
}
// write 写出完整对象表和交叉引用,失败时由调用方丢弃输出
// 入参: writer 输出流, children 页面对象引用
// 返回: error 编码、写入或取消错误
func (p *printPDFObjects) write(writer io.Writer, children pdfgo.Array) error {
p.objects[0] = pdfgo.Dictionary{"Type": pdfgo.Name("Catalog"), "Pages": pdfgo.Reference{Number: 2}}
p.objects[1] = pdfgo.Dictionary{"Type": pdfgo.Name("Pages"), "Count": pdfgo.Integer(len(children)), "Kids": children}
var out bytes.Buffer
out.WriteString("%PDF-1.7\n%\xe2\xe3\xcf\xd3\n")
offsets := make([]int, len(p.objects))
for i, object := range p.objects {
if err := p.ctx.Err(); err != nil {
return err
}
offsets[i] = out.Len()
fmt.Fprintf(&out, "%d 0 obj\n", i+1)
if err := printPDFObject(&out, object, 0); err != nil {
return err
}
out.WriteString("\nendobj\n")
}
xref := out.Len()
fmt.Fprintf(&out, "xref\n0 %d\n0000000000 65535 f \n", len(offsets)+1)
for _, offset := range offsets {
fmt.Fprintf(&out, "%010d 00000 n \n", offset)
}
fmt.Fprintf(&out, "trailer\n<</Size %d/Root 1 0 R>>\nstartxref\n%d\n%%%%EOF\n", len(offsets)+1, xref)
return Write(p.ctx, out.Bytes(), writer, WriteOptions{})
}
// printPDFObject 编码已复制的标准PDF对象,名称和字符串按字节转义
// 入参: out 对象缓冲区, object 待编码对象, depth 当前递归深度
// 返回: error 类型或嵌套错误
func printPDFObject(out *bytes.Buffer, object pdfgo.Object, depth int) error {
if depth > 256 {
return fmt.Errorf("print PDF object nesting too deep")
}
switch value := object.(type) {
case nil:
out.WriteString("null")
case pdfgo.Boolean:
fmt.Fprintf(out, "%t", value)
case pdfgo.Integer:
fmt.Fprintf(out, "%d", value)
case pdfgo.Real:
if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) {
return fmt.Errorf("invalid print PDF number")
}
out.WriteString(strconv.FormatFloat(float64(value), 'f', -1, 64))
case pdfgo.Reference:
fmt.Fprintf(out, "%d %d R", value.Number, value.Generation)
case pdfgo.Name:
out.WriteByte('/')
for _, b := range []byte(value) {
if b <= 32 || b >= 127 || strings.ContainsRune("()<>[]{}/%#", rune(b)) {
fmt.Fprintf(out, "#%02X", b)
} else {
out.WriteByte(b)
}
}
case pdfgo.String:
out.WriteByte('<')
out.WriteString(hex.EncodeToString(value))
out.WriteByte('>')
case pdfgo.Array:
out.WriteByte('[')
for _, item := range value {
if err := printPDFObject(out, item, depth+1); err != nil {
return err
}
out.WriteByte(' ')
}
out.WriteByte(']')
case pdfgo.Dictionary:
out.WriteString("<<")
for _, key := range slices.Sorted(maps.Keys(value)) {
if err := printPDFObject(out, key, depth+1); err != nil {
return err
}
out.WriteByte(' ')
if err := printPDFObject(out, value[key], depth+1); err != nil {
return err
}
out.WriteByte('\n')
}
out.WriteString(">>")
case *pdfgo.Stream:
dict := maps.Clone(value.Dictionary)
dict["Length"] = pdfgo.Integer(len(value.Data))
if err := printPDFObject(out, dict, depth+1); err != nil {
return err
}
out.WriteString("\nstream\n")
out.Write(value.Data)
out.WriteString("\nendstream")
default:
return fmt.Errorf("unsupported print PDF object %T", object)
}
return nil
}
+8
View File
@@ -423,6 +423,7 @@ type businessEngine struct {
currentSession *Session
currentEditor *ofdgo.Editor
saveSnapshot *ofdgo.Editor
printDocument *ofdgo.PrintDocument
pendingImport *ofdgo.Reader
copiedObjects *editorClipboard
copiedStyle *ofdgo.GraphicObject
@@ -7445,6 +7446,11 @@ func (c *outputContext) Err() error {
// businessCommands 共用业务命令表
func (e *businessEngine) businessCommands() map[string]func([]platformValue) (any, error) {
return map[string]func([]platformValue) (any, error){
"ofdgoPreparePrint": e.preparePrint,
"ofdgoLayoutPrint": e.layoutPrint,
"ofdgoPrintSide": e.printSide,
"ofdgoPrintPDF": e.printPDF,
"ofdgoDiscardPrint": e.discardPrint,
"ofdgoDetectFormat": e.detectDocumentFormat,
"ofdgoOpen": e.openDocument,
"ofdgoSelectDocument": e.selectDocument,
@@ -7594,6 +7600,8 @@ func (e *businessEngine) operationContext() context.Context {
func failureResult(err error) apiResult {
result := apiResult{Error: err.Error()}
switch {
case errors.Is(err, ofdgo.ErrPrintDenied):
result.Code, result.Error = "printDenied", "文档禁止打印"
case errors.Is(err, ofdgo.ErrPDFPassword):
result.Code, result.Error = "pdfPassword", "PDF密码缺失或错误"
case errors.Is(err, ofdgo.ErrCredentialsRequired):
+1
View File
@@ -357,6 +357,7 @@ func (e *NativeEngine) Close() error {
e.gate <- struct{}{}
defer func() { <-e.gate }()
e.closed = true
_, _ = e.discardPrint(nil)
_ = e.waitRenders(context.Background())
e.renderIdle = nil
e.saveSnapshot = nil
+141
View File
@@ -0,0 +1,141 @@
// 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 webui
import (
"bytes"
"encoding/json"
"fmt"
"github.com/xiaoqidun/ofdgo"
)
// preparePrint 从当前编辑入口创建独立打印快照,业务选项由公开库统一校验
// 入参: args 排版选项及可选输出任务标识
// 返回: any 纸面布局, error 快照、权限或排版错误
func (e *businessEngine) preparePrint(args []platformValue) (any, error) {
if e.currentSession == nil || len(args) == 0 {
return nil, fmt.Errorf("ofd document is not opened")
}
var options ofdgo.PrintOptions
if err := json.Unmarshal([]byte(platformJSON(args[0])), &options); err != nil {
return nil, err
}
ctx := e.operationContext()
if len(args) > 1 {
ctx = e.newOutputContext(args[1])
}
editor := e.currentEditor
if editor == nil {
var err error
editor, err = e.currentSession.Reader.EditorContext(ctx)
if err != nil {
return nil, err
}
}
source := e.currentSession.Renderer
dirs, fonts := source.FontSources()
document, err := editor.PreparePrint(ctx, options, ofdgo.WithRenderBackends(source.Backends()), ofdgo.WithFontDirs(dirs...), ofdgo.WithFontFS(fonts...))
if err != nil {
return nil, err
}
if e.printDocument != nil {
_ = e.printDocument.Close()
}
e.printDocument = document
data, err := json.Marshal(document.Layout())
if err != nil {
return nil, err
}
return successResult(platformParseJSON(data)), nil
}
// layoutPrint 在既有快照上调整排版,预览期间不重复复制源包
// 入参: args 新排版选项及可选输出任务标识
// 返回: any 新纸面布局, error 状态、权限或排版错误
func (e *businessEngine) layoutPrint(args []platformValue) (any, error) {
if e.printDocument == nil || len(args) == 0 {
return nil, fmt.Errorf("print document is not prepared")
}
var options ofdgo.PrintOptions
if err := json.Unmarshal([]byte(platformJSON(args[0])), &options); err != nil {
return nil, err
}
ctx := e.operationContext()
if len(args) > 1 {
ctx = e.newOutputContext(args[1])
}
next, err := e.printDocument.Reconfigure(ctx, options)
if err != nil {
return nil, err
}
_ = e.printDocument.Close()
e.printDocument = next
data, err := json.Marshal(next.Layout())
if err != nil {
return nil, err
}
return successResult(platformParseJSON(data)), nil
}
// printSide 按需输出一个纸面,不使用阅读区缓存或缩略图
// 入参: args 零基纸面索引及可选输出任务标识
// 返回: any 内嵌资源的SVG纸面, error 状态或渲染错误
func (e *businessEngine) printSide(args []platformValue) (any, error) {
if e.printDocument == nil || len(args) == 0 {
return nil, fmt.Errorf("print document is not prepared")
}
ctx := e.operationContext()
if len(args) > 1 {
ctx = e.newOutputContext(args[1])
}
var output bytes.Buffer
if err := e.printDocument.RenderSVG(ctx, args[0].Int(), &output); err != nil {
return nil, err
}
return successResult(map[string]any{"svg": output.String()}), nil
}
// printPDF 输出和预览相同的打印布局,便于另存或交给第三方打印系统
// 入参: args 可选输出任务标识
// 返回: any PDF数据及媒体类型, error 状态或输出错误
func (e *businessEngine) printPDF(args []platformValue) (any, error) {
if e.printDocument == nil {
return nil, fmt.Errorf("print document is not prepared")
}
ctx := e.operationContext()
if len(args) > 0 {
ctx = e.newOutputContext(args[0])
}
var output bytes.Buffer
if err := e.printDocument.WritePDF(ctx, &output); err != nil {
return nil, err
}
return successResult(map[string]any{"bytes": bytesToJS(output.Bytes()), "mime": "application/pdf"}), nil
}
// discardPrint 释放当前打印快照,可重复调用
// 入参: _ 保留的调用参数
// 返回: any 空结果, error 资源释放错误
func (e *businessEngine) discardPrint(_ []platformValue) (any, error) {
if e.printDocument != nil {
err := e.printDocument.Close()
e.printDocument = nil
if err != nil {
return nil, err
}
}
return successResult(nil), nil
}
+1
View File
@@ -99,6 +99,7 @@ func (w *wasmEngines) close(args []js.Value) (any, error) {
return nil, nil
}
delete(w.engines, id)
_, _ = e.discardPrint(nil)
e.clearImport()
err := e.currentSession.Close()
*e = businessEngine{}
+40 -1
View File
@@ -20,6 +20,7 @@ import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
@@ -106,7 +107,7 @@ func Run(ctx context.Context, paths []string, dataDir string) error {
h.conversionPool = h.resources.ConversionPool()
defer h.close()
bindings := make(map[string]guigo.HandlerFunc)
for _, name := range []string{"engine.open", "engine.close", "engine.call", "engine.start", "engine.cancel", "engine.finish", "batch", "open", "save", "directory", "file", "release", "drop.release", "child", "remove", "write.begin", "write.data", "write.commit", "write.abort", "fonts", "ready", "title", "window.info", "window.drag", "window.close", "update.check", "update.open"} {
for _, name := range []string{"engine.open", "engine.close", "engine.call", "engine.start", "engine.cancel", "engine.finish", "batch", "open", "save", "directory", "file", "release", "drop.release", "child", "remove", "write.begin", "write.data", "write.commit", "write.abort", "fonts", "ready", "title", "window.info", "window.drag", "window.close", "update.check", "update.open", "print.capabilities", "print.dialog", "print.printers", "print.query", "print.properties", "print"} {
bindings["ofdgo.host."+name] = func(ctx context.Context, args any) (any, error) { return h.call(ctx, name, args) }
}
static := http.FileServerFS(webuiassets.NativeFS)
@@ -305,6 +306,44 @@ func (h *Host) call(ctx context.Context, name string, value any) (any, error) {
text := func(key string) string { value, _ := args[key].(string); return value }
flag := func(key string) bool { value, _ := args[key].(bool); return value }
switch name {
case "print.capabilities", "print.dialog", "print.printers", "print.query", "print.properties", "print":
h.mu.Lock()
window := h.window
h.mu.Unlock()
if window == nil {
return nil, fmt.Errorf("窗口尚未就绪")
}
if name == "print.capabilities" {
return window.PrintCapabilities(ctx)
}
if name == "print.dialog" {
return nil, window.ShowPrintUI(ctx, text("kind"))
}
if name == "print.printers" {
return window.ListPrinters(ctx)
}
if name == "print.properties" {
return nil, window.ShowPrinterProperties(ctx, text("printer"))
}
data, err := json.Marshal(args)
if err != nil {
return nil, err
}
var options guigo.PrintOptions
if err := json.Unmarshal(data, &options); err != nil {
return nil, err
}
profile, err := window.QueryPrinter(ctx, options)
if err != nil {
return nil, err
}
if name == "print.query" {
return profile, nil
}
if text("fingerprint") == "" || text("fingerprint") != profile.Fingerprint {
return nil, fmt.Errorf("打印机设置已变化,请重新预览")
}
return window.Print(ctx, options)
case "engine.open":
engine := webui.NewNativeEngine(h.files.resolve)
parallelism, cacheBytes := runtime.GOMAXPROCS(0), int64(128<<20)