refactor(重构提升): 完善水印接口与编辑稳定性
build.yaml / build (push) Successful in 43s

This commit is contained in:
xiaoqidun committed 2026-09-29 23:47:50 +08:00
1 parent accd0a2973
commit fef7e75920
8 files changed
+263 -140

No files matched your search

+1 -1
View File
@@ -3618,7 +3618,7 @@ async function openOFD(file, remote = null) {
await openDocument({ doc, pageIndex: remote?.pageIndex || 0, resetScroll: true, openSeq });
} catch (err) {
if (openSeq === state.openSeq) {
if (err.name === "AbortError") setStatus("转换已取消");
if (err.name === "AbortError") setStatus("打开已取消");
else showError(err, !state.doc);
setBusy(false);
}
+1 -1
View File
@@ -9,7 +9,7 @@ require (
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c
github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6
github.com/xiaoqidun/jbig2 v1.1.1
github.com/xiaoqidun/pdfgo v0.0.0-20260929145256-b04a96874771
github.com/xiaoqidun/pdfgo v0.0.0-20260929154445-b9e68e6f6960
golang.org/x/crypto v0.57.0
golang.org/x/image v0.46.0
golang.org/x/text v0.42.0
+2 -2
View File
@@ -62,8 +62,8 @@ github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/xiaoqidun/jbig2 v1.1.1 h1:SjliS9ZO0e9HX5oYUfBuXrfFsXhWricrSz+XY4zORDI=
github.com/xiaoqidun/jbig2 v1.1.1/go.mod h1:8QonVWtENmh2kc6l+leqeGl7psb79DWlHDkCpdChjfQ=
github.com/xiaoqidun/pdfgo v0.0.0-20260929145256-b04a96874771 h1:qddSlHkgZhRMyawpOS3TlW5nu8+IurZFjxS4Kwg8YLA=
github.com/xiaoqidun/pdfgo v0.0.0-20260929145256-b04a96874771/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ=
github.com/xiaoqidun/pdfgo v0.0.0-20260929154445-b9e68e6f6960 h1:msQWyUBuNUnvJ+VKrUELivQPYFxSVanp/iSncN1c+yA=
github.com/xiaoqidun/pdfgo v0.0.0-20260929154445-b9e68e6f6960/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
+35 -133
View File
@@ -545,7 +545,12 @@ func packFiles(args []js.Value) (any, error) {
source := &browserReaderAt{read: read, checkpoint: args[4], size: int64(file.Get("size").Float())}
entries[i] = ofdgo.ArchiveFile{Name: name, Source: source, Size: source.size}
}
options := ofdgo.ArchiveOptions{OnProgress: func(completed, total int) error { return awaitExport(args[3], completed, total) }}
options := ofdgo.ArchiveOptions{OnProgress: func(completed, total int) error {
if completed > 0 {
entries[completed-1].Source.(*browserReaderAt).data = nil
}
return awaitExport(args[3], completed, total)
}}
if len(args) > 5 && !args[5].IsUndefined() && !args[5].IsNull() {
if err := compressionFromJS(args[5], &options.Compression); err != nil {
return nil, err
@@ -1422,7 +1427,6 @@ func writeAnnotation(args []js.Value) (any, error) {
var ids []string
result, err := changeObjects(func() error {
font, resource := "", ""
var fontData []byte
if !args[2].IsNull() {
data, err := bytesFromJS(args[2])
if err != nil {
@@ -1432,7 +1436,6 @@ func writeAnnotation(args []js.Value) (any, error) {
if err != nil {
return err
}
fontData = data
}
if !args[3].IsNull() {
data, err := bytesFromJS(args[3])
@@ -1475,11 +1478,31 @@ func writeAnnotation(args []js.Value) (any, error) {
if err != nil {
return err
}
var watermarkBounds ofdgo.Box
if options.Kind == "watermark" {
if area := options.Area; area != nil && (area.X < 0 || area.Y < 0 || area.W < 0 || area.H < 0 || area.X+area.W > 1 || area.Y+area.H > 1 || options.Tile && (area.W == 0 || area.H == 0)) {
return fmt.Errorf("invalid watermark area")
boundary := fmt.Sprintf("0 0 %g %g", options.Width, options.Height)
var object ofdgo.GraphicObject
if resource != "" {
object = ofdgo.GraphicObject{Type: "ImageObject", ImageObject: ofdgo.ImageObject{ResourceID: resource, Boundary: boundary, Alpha: &options.Alpha}}
} else {
text := ofdgo.TextObject{Font: font, Size: options.Size, Boundary: boundary, Alpha: &options.Alpha}
if err := currentEditor.LayoutText(&text, options.Text, ofdgo.TextLayout{Align: "center"}); err != nil {
return err
}
if err := setTextColor(&text, options.Color); err != nil {
return err
}
object = ofdgo.GraphicObject{Type: "TextObject", TextObject: text}
}
added, err := currentEditor.AddWatermarks(context.Background(), pages, object, ofdgo.WatermarkOptions{Creator: options.Creator, Angle: options.Angle, Tile: options.Tile, Area: options.Area})
if err != nil {
return err
}
for i, index := range pages {
if index == page {
ids = append(ids, "annotation:"+added[i])
}
}
return nil
}
for _, index := range pages {
box := ofdgo.Box{X: options.X, Y: options.Y, W: options.Width, H: options.Height}
@@ -1491,56 +1514,20 @@ func writeAnnotation(args []js.Value) (any, error) {
switch options.Kind {
case "note":
annotation.Type, annotation.Remark = "Path", options.Text
case "watermark", "stamp":
annotation.Type = "Watermark"
if options.Kind == "stamp" {
case "stamp":
annotation.Type = "Stamp"
}
var object ofdgo.GraphicObject
if resource != "" {
object = ofdgo.GraphicObject{Type: "ImageObject", ImageObject: ofdgo.ImageObject{ResourceID: resource, Boundary: fmt.Sprintf("0 0 %g %g", box.W, box.H), CTM: fmt.Sprintf("%g 0 0 %g 0 0", box.W, box.H), Alpha: &options.Alpha}}
} else {
if options.Kind == "stamp" {
if resource == "" {
return fmt.Errorf("stamp requires an image")
}
text := ofdgo.TextObject{Font: font, Size: options.Size, Boundary: fmt.Sprintf("0 0 %g %g", box.W, box.H), Alpha: &options.Alpha}
if err := currentEditor.LayoutText(&text, options.Text, ofdgo.TextLayout{Align: "center"}); err != nil {
return err
}
if err := setTextColor(&text, options.Color); err != nil {
return err
}
object = ofdgo.GraphicObject{Type: "TextObject", TextObject: text}
}
object := ofdgo.GraphicObject{Type: "ImageObject", ImageObject: ofdgo.ImageObject{ResourceID: resource, Boundary: fmt.Sprintf("0 0 %g %g", box.W, box.H), CTM: fmt.Sprintf("%g 0 0 %g 0 0", box.W, box.H), Alpha: &options.Alpha}}
angle := options.Angle * math.Pi / 180
matrix := ofdgo.NewMatrix(fmt.Sprintf("%g %g %g %g 0 0", math.Cos(angle), math.Sin(angle), -math.Sin(angle), math.Cos(angle)))
matrix = ofdgo.TranslationMatrix(box.W/2, box.H/2).Multiply(matrix).Multiply(ofdgo.TranslationMatrix(-box.W/2, -box.H/2))
if object.Type == "TextObject" {
object.TextObject.CTM = matrix.String()
} else {
object.ImageObject.CTM = matrix.Multiply(ofdgo.NewMatrix(object.ImageObject.CTM)).String()
}
annotation.Appearance.Objects = []ofdgo.GraphicObject{object}
default:
return fmt.Errorf("unsupported annotation kind %q", options.Kind)
}
if options.Kind == "watermark" {
if watermarkBounds.W == 0 {
watermarkBounds, err = measureWatermark(annotation.Appearance.Objects[0], fontData, currentEditor.Backends())
if err != nil {
return err
}
}
content, err := currentSession.pageContent(index)
if err != nil {
return err
}
page, err := currentSession.pageBox(index, content)
if err != nil {
return err
}
layoutWatermark(&annotation, watermarkBounds, options, page)
}
id, err = currentEditor.AddAnnotation(index, annotation)
}
if err != nil {
@@ -1558,94 +1545,6 @@ func writeAnnotation(args []js.Value) (any, error) {
return editorSelectionInfo{editorInfo: result.(editorInfo), SelectedIDs: ids}, nil
}
// measureWatermark 复用标准渲染度量水印,临时文档保留尚未提交的字体引用
// 入参: object 水印图元, fontData 字体字节, backends 当前后端配置
// 返回: ofdgo.Box 实际绘制范围, error 错误信息
func measureWatermark(object ofdgo.GraphicObject, fontData []byte, backends ofdgo.RenderBackends) (ofdgo.Box, error) {
if object.Type == "ImageObject" {
return ofdgo.NewMatrix(object.ImageObject.CTM).TransformBox(ofdgo.Box{W: 1, H: 1}), nil
}
editor := ofdgo.NewEditor()
if _, err := editor.AddPage(210, 297); err != nil {
return ofdgo.Box{}, err
}
font, err := editor.AddFont(FontFile{Data: fontData}, 0)
if err != nil {
return ofdgo.Box{}, err
}
object.TextObject.Font, object.TextObject.Alpha = font, nil
if _, err := editor.AddObject(0, object); err != nil {
return ofdgo.Box{}, err
}
reader, err := editor.Reader()
if err != nil {
return ofdgo.Box{}, err
}
defer reader.Close()
if _, err := reader.PageContentByIndex(0); err != nil {
return ofdgo.Box{}, err
}
bounds, err := ofdgo.NewRenderer(reader, ofdgo.WithRenderBackends(backends)).ObjectBounds(ofdgo.GraphicObject{Type: "CompositeObject", CompositeGraphicUnit: ofdgo.CompositeGraphicUnit{
Boundary: object.TextObject.Boundary, Objects: []ofdgo.GraphicObject{object},
}}, "")
if err != nil {
return ofdgo.Box{}, err
}
if bounds.W <= 0 || bounds.H <= 0 {
return ofdgo.Box{}, fmt.Errorf("watermark has no visible content")
}
return bounds, nil
}
// layoutWatermark 按实际绘制边界居中水印或均匀平铺,选区按页面比例映射
// 入参: annotation 水印注解, bounds 绘制范围, options 输入选项, page 页面范围
func layoutWatermark(annotation *ofdgo.Annotation, bounds ofdgo.Box, options annotationOptions, page ofdgo.Box) {
base := annotation.Appearance.Objects[0]
area := ofdgo.Box{W: page.W, H: page.H}
if options.Area != nil {
area = ofdgo.Box{X: options.Area.X * page.W, Y: options.Area.Y * page.H, W: options.Area.W * page.W, H: options.Area.H * page.H}
}
scale := 1.0
if area.W > 0 && area.H > 0 {
scale = math.Min(1, math.Min(area.W/bounds.W, area.H/bounds.H))
}
if scale != 1 {
matrix := ofdgo.NewMatrix(fmt.Sprintf("%g 0 0 %g 0 0", scale, scale))
if base.Type == "TextObject" {
base.TextObject.CTM = matrix.Multiply(ofdgo.NewMatrix(base.TextObject.CTM)).String()
} else {
base.ImageObject.CTM = matrix.Multiply(ofdgo.NewMatrix(base.ImageObject.CTM)).String()
}
bounds = matrix.TransformBox(bounds)
}
columns, rows := 1, 1
if options.Tile {
angle := options.Angle * math.Pi / 180
width := math.Max(bounds.W, (math.Abs(math.Cos(angle))*options.Width+math.Abs(math.Sin(angle))*options.Height)*scale)
height := math.Max(bounds.H, (math.Abs(math.Sin(angle))*options.Width+math.Abs(math.Cos(angle))*options.Height)*scale)
columns = max(1, int((area.W+10)/(width+10)))
rows = max(1, int((area.H+10)/(height+10)))
} else {
area = ofdgo.Box{X: area.X + (area.W-bounds.W)/2, Y: area.Y + (area.H-bounds.H)/2, W: bounds.W, H: bounds.H}
}
annotation.Appearance.Boundary = fmt.Sprintf("%g %g %g %g", area.X, area.Y, area.W, area.H)
annotation.Appearance.Objects = make([]ofdgo.GraphicObject, 0, columns*rows)
for row := range rows {
for column := range columns {
x := (float64(column)+0.5)*area.W/float64(columns) - bounds.X - bounds.W/2
y := (float64(row)+0.5)*area.H/float64(rows) - bounds.Y - bounds.H/2
object := base
boundary := fmt.Sprintf("%g %g %g %g", x, y, options.Width, options.Height)
if object.Type == "TextObject" {
object.TextObject.Boundary = boundary
} else {
object.ImageObject.Boundary = boundary
}
annotation.Appearance.Objects = append(annotation.Appearance.Objects, object)
}
}
}
// insertInk 将一笔采样写为一个标准矢量对象,复用内部插入与撤销逻辑
// 入参: args 页面、JSON采样、线宽、压感开关、颜色及可选父路径
// 返回: any 文档和选区, error 错误信息
@@ -4473,6 +4372,9 @@ func saveDocument(args []js.Value) (any, error) {
// 入参: editor 待写出文档, args 写出回调、进度回调及可选页面索引、压缩配置、取消检查
// 返回: any 保存结果, error 错误信息
func saveEditor(editor *ofdgo.Editor, args []js.Value) (any, error) {
if len(args) == 0 || args[0].Type() != js.TypeFunction {
return nil, fmt.Errorf("missing output callback")
}
if len(args) > 1 {
editor.OnWriteProgress = editorOperationProgress(args[1])
defer func() { editor.OnWriteProgress = nil }()
+4 -1
View File
@@ -78,8 +78,11 @@ func (e *Editor) compressResourceParts(parts map[string][]byte, reader *Reader,
return err
}
}
resource, safe := refs.scan(input, name)
resource, safe := refs.scan(imageInput{ReadCloser: input, context: e.output.ctx}, name)
input.Close()
if err := e.output.ctx.Err(); err != nil {
return err
}
if !safe {
return nil
}
+7 -1
View File
@@ -37,8 +37,13 @@ func (e *Editor) Transaction(edit func(*Editor) error) error {
next := e.transactionSnapshot()
next.history, next.historyIndex, next.historyLimit = nil, 0, 0
*e = next
if err := edit(e); err != nil {
committed := false
defer func() {
if !committed {
*e = before
}
}()
if err := edit(e); err != nil {
return err
}
changed := e.revision != before.revision
@@ -54,6 +59,7 @@ func (e *Editor) Transaction(edit func(*Editor) error) error {
change.redo = func(e *Editor) { e.restoreTransaction(after) }
}
}
committed = true
return nil
}
+212
View File
@@ -0,0 +1,212 @@
// 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"
"math"
)
// WatermarkOptions 配置水印旋转、居中和平铺,Area为页面比例区域,nil使用整页
// Angle为顺时针角度,Gap为平铺最小毫米间距,零值使用10毫米
// 非平铺时Area宽高同时为零表示中心点定位,不缩放图元
type WatermarkOptions struct {
Creator string
Angle float64
Tile bool
Area *Box
Gap float64
}
// AddWatermarks 向指定页面添加文字或图片水印,复用编辑器字体来源及后端,一次操作计入一条撤销记录
// 图元Boundary提供原始尺寸,忽略其位置;按实际绘制范围居中,超出区域时等比缩小
// 失败或取消时保留原文档,输入图元不被修改
// 入参: ctx 取消上下文, indexes 不重复的页面索引, object 水印图元, options 布局选项
// 返回: []string 按页面顺序排列的注解标识, error 错误信息
func (e *Editor) AddWatermarks(ctx context.Context, indexes []int, object GraphicObject, options WatermarkOptions) ([]string, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if err := e.validatePageIndexes(indexes); err != nil {
return nil, err
}
if !finite(options.Angle) || !finite(options.Gap) || options.Gap < 0 {
return nil, fmt.Errorf("invalid watermark angle or gap")
}
if area := options.Area; area != nil {
point := !options.Tile && area.W == 0 && area.H == 0
if !finite(area.X) || !finite(area.Y) || !finite(area.W) || !finite(area.H) || area.X < 0 || area.Y < 0 || (!point && (area.W <= 0 || area.H <= 0)) || area.X+area.W > 1 || area.Y+area.H > 1 {
return nil, fmt.Errorf("invalid watermark area")
}
}
if options.Gap == 0 {
options.Gap = 10
}
object = cloneEditorData(object)
var boundary, ctm *string
switch object.Type {
case "TextObject":
boundary, ctm = &object.TextObject.Boundary, &object.TextObject.CTM
case "ImageObject":
boundary, ctm = &object.ImageObject.Boundary, &object.ImageObject.CTM
default:
return nil, fmt.Errorf("watermark requires text or image")
}
box, err := creationBox(*boundary)
if err != nil || box.W <= 0 || box.H <= 0 {
return nil, fmt.Errorf("invalid watermark dimensions")
}
if len(indexes) == 0 {
return nil, nil
}
box.X, box.Y = 0, 0
*boundary = editorBoxString(box)
angle := math.Mod(options.Angle, 360) * math.Pi / 180
rotation := Matrix{a: math.Cos(angle), b: math.Sin(angle), c: -math.Sin(angle), d: math.Cos(angle)}
rotation = TranslationMatrix(box.W/2, box.H/2).Multiply(rotation).Multiply(TranslationMatrix(-box.W/2, -box.H/2))
if *ctm != "" {
if _, err := creationNumbers(*ctm, 6); err != nil {
return nil, err
}
} else if object.Type == "ImageObject" {
*ctm = (Matrix{a: box.W, d: box.H}).String()
}
*ctm = rotation.Multiply(NewMatrix(*ctm)).String()
preview := e.transactionSnapshot()
preview.SetHistoryLimit(0)
id, err := preview.AddObject(indexes[0], object)
if err != nil {
return nil, err
}
object, err = preview.Object(indexes[0], id)
if err != nil {
return nil, err
}
reader, err := preview.Reader()
if err != nil {
return nil, err
}
defer reader.Close()
page, err := reader.PageContentByIndex(indexes[0])
if err != nil {
return nil, err
}
renderer := e.newRenderer(reader)
measured := cloneEditorData(object)
if measured.Type == "TextObject" {
measured.TextObject.Alpha = nil
} else {
measured.ImageObject.Alpha = nil
}
bounds, err := renderer.ObjectBounds(measured, "")
if err != nil {
return nil, err
}
if !finite(bounds.X) || !finite(bounds.Y) || !finite(bounds.W) || !finite(bounds.H) || bounds.W <= 0 || bounds.H <= 0 {
return nil, fmt.Errorf("watermark has no visible content")
}
var ids []string
err = e.Transaction(func(edit *Editor) error {
for _, index := range indexes {
if err := ctx.Err(); err != nil {
return err
}
if index != indexes[0] {
page, err = reader.PageContentByIndex(index)
if err != nil {
return err
}
}
pageBox, err := renderer.GetPageBox(page)
if err != nil {
return err
}
annotation, err := layoutWatermark(ctx, object, bounds, box, options, pageBox)
if err != nil {
return err
}
id, err := edit.AddAnnotation(index, annotation)
if err != nil {
return err
}
ids = append(ids, id)
}
return ctx.Err()
})
if err != nil {
return nil, err
}
return ids, nil
}
// layoutWatermark 按绘制范围布局独立水印外观,保留页面原点及图元变换
// 入参: ctx 取消上下文, base 水印图元, bounds 绘制范围, box 原始尺寸, options 布局选项, page 页面范围
// 返回: Annotation 水印注解, error 取消或布局错误
func layoutWatermark(ctx context.Context, base GraphicObject, bounds, box Box, options WatermarkOptions, page Box) (Annotation, error) {
area := page
if options.Area != nil {
area = Box{X: page.X + options.Area.X*page.W, Y: page.Y + options.Area.Y*page.H, W: options.Area.W * page.W, H: options.Area.H * page.H}
}
if !finite(page.X) || !finite(page.Y) || !finite(page.W) || !finite(page.H) || page.W <= 0 || page.H <= 0 {
return Annotation{}, fmt.Errorf("invalid watermark page area")
}
scale := 1.0
if area.W > 0 && area.H > 0 {
scale = math.Min(1, math.Min(area.W/bounds.W, area.H/bounds.H))
}
if scale != 1 {
matrix := Matrix{a: scale, d: scale}
if base.Type == "TextObject" {
base.TextObject.CTM = matrix.Multiply(NewMatrix(base.TextObject.CTM)).String()
} else {
base.ImageObject.CTM = matrix.Multiply(NewMatrix(base.ImageObject.CTM)).String()
}
bounds = matrix.TransformBox(bounds)
}
columns, rows := 1.0, 1.0
if options.Tile {
angle := math.Mod(options.Angle, 360) * math.Pi / 180
width := math.Max(bounds.W, (math.Abs(math.Cos(angle))*box.W+math.Abs(math.Sin(angle))*box.H)*scale)
height := math.Max(bounds.H, (math.Abs(math.Sin(angle))*box.W+math.Abs(math.Cos(angle))*box.H)*scale)
columns = math.Max(1, math.Floor((area.W+options.Gap)/(width+options.Gap)))
rows = math.Max(1, math.Floor((area.H+options.Gap)/(height+options.Gap)))
} else {
area = Box{X: area.X + (area.W-bounds.W)/2, Y: area.Y + (area.H-bounds.H)/2, W: bounds.W, H: bounds.H}
}
if !finite(columns*rows) || columns*rows > 100000 {
return Annotation{}, fmt.Errorf("too many watermark tiles")
}
annotation := Annotation{Type: "Watermark", Creator: options.Creator, Appearance: Appearance{Boundary: editorBoxString(area)}}
annotation.Appearance.Objects = make([]GraphicObject, 0, int(columns*rows))
for row := 0; row < int(rows); row++ {
if err := ctx.Err(); err != nil {
return Annotation{}, err
}
for column := 0; column < int(columns); column++ {
x := (float64(column)+0.5)*area.W/columns - bounds.X - bounds.W/2
y := (float64(row)+0.5)*area.H/rows - bounds.Y - bounds.H/2
object := base
boundary := editorBoxString(Box{X: x, Y: y, W: box.W, H: box.H})
if object.Type == "TextObject" {
object.TextObject.Boundary = boundary
} else {
object.ImageObject.Boundary = boundary
}
annotation.Appearance.Objects = append(annotation.Appearance.Objects, object)
}
}
return annotation, nil
}
+1 -1
View File
@@ -40,7 +40,7 @@ type imageOutput struct {
writer io.Writer
}
// imageInput 在读取原始图片时检查调用方取消状态
// imageInput 在读取资源数据时检查调用方取消状态
type imageInput struct {
io.ReadCloser
context context.Context