diff --git a/assets/webui/index.html b/assets/webui/index.html index 87bd6b8..a63b0c1 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -290,6 +290,8 @@ +
+
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 205df7a..0912407 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -141,6 +141,7 @@ a:focus-visible { .object-picker-list { display: grid; gap: 4px; + margin-bottom: 12px; max-height: 55vh; overflow-y: auto; } @@ -657,6 +658,12 @@ button:not(:disabled):hover { gap: 12px; } +.annotation-placement { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; +} + .form-row input[type="checkbox"] { justify-self: start; margin: 0; diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 8ecbf84..62731a7 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -363,7 +363,7 @@ const fontManager = new FontManager({ onPermissionChange: updateFontPermissionHint, }); const fontPicker = new FontPicker(el.textFont, el.textFontToggle, el.textFontList, changeTextFont, loadEditorFonts); -const annotationFields = Object.fromEntries(["CreateForm", "CreateCancel", "Content", "Old", "Font", "Text", "TextRow", "Image", "ImageRow", "Author", "LinkKind", "Address", "AddressRow", "Target", "TargetRow", "X", "Y", "Width", "Height", "Angle", "Opacity", "Tile", "Pages"].map(key => [key, document.querySelector(`#annotation${key}`)])); +const annotationFields = Object.fromEntries(["CreateForm", "CreateCancel", "Content", "Old", "Font", "Text", "TextRow", "Image", "ImageRow", "Author", "LinkKind", "Address", "AddressRow", "Target", "TargetRow", "X", "Y", "Width", "Height", "Angle", "Opacity", "Tile", "Placement", "PlacementLabel", "Pick", "Scope", "Pages"].map(key => [key, document.querySelector(`#annotation${key}`)])); el.editorTools.addEventListener("scroll", () => fontPicker.position()); const canvasEditor = new CanvasEditor(el.viewerPanel, { @@ -412,11 +412,26 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, { return true; }, drawStyle: shapeStyle, - onTool: updateDrawingControls, + onTool: () => { + updateDrawingControls(); + const entry = state.annotationCreate; + if (entry?.picking && canvasEditor.tool !== "annotation:watermark") { + queueMicrotask(() => { + if (state.annotationCreate === entry && entry.picking) { + entry.picking = false; + if (!state.editing || entry.openSeq !== state.openSeq || canvasEditor.tool) { + state.annotationCreate = null; + return; + } + el.annotationCreate.showModal(); + } + }); + } + }, onDraw: (index, shape, box, style) => changeDocument("ofdgoInsertShape", null, index, shape, box.x, box.y, box.width, box.height, style.fill, style.fillColor, style.stroke, style.strokeColor, style.lineWidth), onDrawText: beginCanvasText, - onDrawAnnotation: (index, kind, box) => openAnnotationCreate(kind, index, box), + onDrawAnnotation: placeAnnotation, onDrawInk: (index, points, style, pressure) => changeDocument("ofdgoInsertInk", null, index, JSON.stringify(points), style.lineWidth, pressure, style.strokeColor), onCommitText: (item, value, fontData, color = null) => item.draft ? changeDocument("ofdgoInsertText", null, item.index, value, fontData || item.fontData, item.x, item.y, item.size, item.color, item.width, item.wrap, item.align, item.paragraphHeight, item.letterSpacing, ...textIndents(item)) @@ -541,17 +556,24 @@ el.annotationTool.addEventListener("change", async () => { } }); annotationFields.CreateCancel.addEventListener("click", () => el.annotationCreate.close()); -el.annotationCreate.addEventListener("close", () => { state.annotationCreate = null; }); +el.annotationCreate.addEventListener("close", () => { + if (!state.annotationCreate?.picking) state.annotationCreate = null; +}); annotationFields.Content.addEventListener("change", updateAnnotationFields); annotationFields.Height.addEventListener("input", () => { state.annotationCreate.height = annotationFields.Height.value; }); annotationFields.LinkKind.addEventListener("change", updateAnnotationFields); annotationFields.Old.addEventListener("change", () => { annotationFields.Text.value = annotationFields.Old.value; }); annotationFields.Tile.addEventListener("change", () => { - if (annotationFields.Tile.value === "tile") { - annotationFields.X.value = 10; - annotationFields.Y.value = 10; - } + annotationFields.Placement.value = "page"; + state.annotationCreate.area = null; + updateAnnotationFields(); }); +annotationFields.Placement.addEventListener("change", () => { + if (annotationFields.Placement.value === "custom") pickWatermarkArea(); + else state.annotationCreate.area = null; +}); +annotationFields.Pick.addEventListener("click", pickWatermarkArea); +annotationFields.Scope.addEventListener("change", updateAnnotationFields); annotationFields.CreateForm.addEventListener("submit", async event => { event.preventDefault(); const entry = state.annotationCreate; @@ -569,9 +591,12 @@ annotationFields.CreateForm.addEventListener("submit", async event => { } if (openSeq !== state.openSeq || state.annotationCreate !== entry) return; const options = { kind: entry.kind, id: entry.item?.id.replace("annotation:", "") || "", text: value("Text"), old: value("Old"), - creator: value("Author"), pages: value("Pages"), uri: value("Address"), target: value("LinkKind") === "page" ? Number(value("Target")) : 0, + creator: value("Author"), pages: entry.kind === "watermark" && value("Scope") !== "custom" + ? value("Scope") === "all" ? `1-${state.doc.pageCount}` : String(entry.index + 1) : value("Pages"), + uri: value("Address"), target: value("LinkKind") === "page" ? Number(value("Target")) : 0, x: Number(value("X")), y: Number(value("Y")), width: entry.kind === "note" ? 5 : Number(value("Width")), height: entry.kind === "note" ? 5 : Number(value("Height")), angle: Number(value("Angle")), alpha: Math.round((100 - Number(value("Opacity"))) * 255 / 100), tile: value("Tile") === "tile", + area: entry.kind === "watermark" && value("Placement") === "custom" ? entry.area : null, size: state.textDefaults.size, color: state.textDefaults.color }; if (entry.kind === "link" && entry.item) { options.base = entry.info?.linkBase || ""; @@ -1455,6 +1480,8 @@ async function createDocument(event) { state.editorViews.clear(); state.styleClipboard = null; state.fontRenderPending = false; + state.annotationCreate = null; + canvasEditor.setTool(""); canvasEditor.clear(); delete state.textDefaults.fontChoice; updateTextFonts(null, true); @@ -1514,7 +1541,7 @@ function openObjectPicker(items = canvasEditor.pageItems(state.pageIndex)) { function openAnnotationCreate(kind, index, box = null, item = null, info = null) { const page = state.doc.pages[index]; const width = Math.min(60, page.width), height = 12; - state.annotationCreate = { kind, index, item, info, height: box?.height || height }; + state.annotationCreate = { kind, index, item, info, openSeq: state.openSeq, height: box?.height || height }; annotationFields.CreateForm.reset(); annotationFields.Text.value = kind === "watermark" ? "水印" : ""; annotationFields.Pages.value = String(index + 1); @@ -1541,6 +1568,38 @@ function openAnnotationCreate(kind, index, box = null, item = null, info = null) focus?.focus(); } +function pickWatermarkArea() { + state.annotationCreate.picking = true; + annotationFields.Placement.value = state.annotationCreate.area ? "custom" : "page"; + el.annotationCreate.close(); + canvasEditor.select(null); + toggleDrawingTool("annotation:watermark"); +} + +function placeAnnotation(index, kind, box) { + const entry = state.annotationCreate; + if (kind !== "watermark" || !entry?.picking) { + openAnnotationCreate(kind, index, box); + return; + } + entry.picking = false; + const page = state.doc.pages[index], tile = annotationFields.Tile.value === "tile"; + const x = Math.max(0, Math.min(1, box.x / page.width)); + const y = Math.max(0, Math.min(1, box.y / page.height)); + const right = Math.max(x, Math.min(1, (box.x + box.width) / page.width)); + const bottom = Math.max(y, Math.min(1, (box.y + box.height) / page.height)); + if (!tile || right > x && bottom > y) { + entry.index = index; + entry.area = tile ? { x, y, w: right - x, h: bottom - y } : { x: (x + right) / 2, y: (y + bottom) / 2, w: 0, h: 0 }; + annotationFields.Placement.value = "custom"; + if (annotationFields.Scope.value !== "custom") annotationFields.Pages.value = String(index + 1); + el.annotationCreateStatus.textContent = ""; + } else { + el.annotationCreateStatus.textContent = "范围过小"; + } + el.annotationCreate.showModal(); +} + function updateAnnotationFields() { const { kind, item } = state.annotationCreate; for (const key of ["X", "Y", "Width", "Height", "Pages", "Author"]) annotationFields[key].closest(".form-row").hidden = false; @@ -1553,6 +1612,15 @@ function updateAnnotationFields() { annotationFields.TargetRow.hidden = kind !== "link" || annotationFields.LinkKind.value !== "page"; annotationFields.Height.value = image ? "" : state.annotationCreate.height; annotationFields.Height.placeholder = image ? "等比" : ""; + if (kind === "watermark") { + for (const key of ["X", "Y"]) annotationFields[key].closest(".form-row").hidden = true; + annotationFields.Pages.closest(".form-row").hidden = annotationFields.Scope.value !== "custom"; + const tile = annotationFields.Tile.value === "tile"; + annotationFields.PlacementLabel.textContent = tile ? "范围" : "位置"; + annotationFields.Placement.options[0].textContent = tile ? "整页" : "居中"; + annotationFields.Placement.options[1].textContent = tile ? "选区" : "指定"; + annotationFields.Pick.textContent = tile ? "框选" : "定位"; + } if (kind === "link" && item) { for (const key of ["X", "Y", "Width", "Height", "Pages", "Author"]) annotationFields[key].closest(".form-row").hidden = true; } @@ -2734,6 +2802,7 @@ async function openOFD(file) { state.fileName = file.name || "ofdgo.ofd"; state.editing = false; state.composite = null; + state.annotationCreate = null; state.pageSelection.clear(); state.pageSelectionAnchor = null; state.pageMultiSelect = false; diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index 84c7c8d..26e9e4a 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -783,7 +783,7 @@ export class CanvasEditor { this.viewer.releasePointerCapture(drag.pointerID); if (drag.annotation) { const point = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.page, drag.rotation); - const box = drag.box || { ...point, width: drag.annotation === "note" ? 5 : 40, height: drag.annotation === "note" ? 5 : 12 }; + const box = drag.box || { ...point, width: drag.annotation === "watermark" ? 0 : drag.annotation === "note" ? 5 : 40, height: drag.annotation === "watermark" ? 0 : drag.annotation === "note" ? 5 : 12 }; drag.preview.remove(); this.setTool(""); this.options.onDrawAnnotation(drag.page.index, drag.annotation, box); @@ -975,6 +975,9 @@ export class CanvasEditor { const annotation = this.tool.startsWith("annotation:") ? this.tool.slice(11) : ""; const shape = annotation ? "rectangle" : this.tool; const style = this.options.drawStyle(); + if (annotation === "watermark") { + Object.assign(style, { fill: false, stroke: true, strokeColor: "var(--accent)", lineWidth: 1 }); + } if (lineShape(shape)) { style.fill = false; style.stroke = true; @@ -982,6 +985,7 @@ export class CanvasEditor { this.drag = { shape, annotation, page, surface, ...this.createPreview(surface, page, shape === "text" ? "rectangle" : shape, style), style, pointerID: event.pointerId, rect: surface.getBoundingClientRect(), rotation: this.options.rotation(), clientX: event.clientX, clientY: event.clientY }; + if (annotation === "watermark") this.drag.node.setAttribute("vector-effect", "non-scaling-stroke"); this.viewer.setPointerCapture(event.pointerId); } diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 3dd0a52..5cee3e5 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -945,6 +945,7 @@ type annotationOptions struct { Tile bool Keep bool Target int + Area *ofdgo.Box } // readAnnotation 获取编辑面板所需的注解内容,不修改文件 @@ -1011,6 +1012,7 @@ 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 { @@ -1020,6 +1022,7 @@ func writeAnnotation(args []js.Value) (any, error) { if err != nil { return err } + fontData = data } if !args[3].IsNull() { data, err := bytesFromJS(args[3]) @@ -1062,6 +1065,12 @@ 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") + } + } for _, index := range pages { box := ofdgo.Box{X: options.X, Y: options.Y, W: options.Width, H: options.Height} var id string @@ -1105,7 +1114,13 @@ func writeAnnotation(args []js.Value) (any, error) { default: return fmt.Errorf("unsupported annotation kind %q", options.Kind) } - if options.Tile && options.Kind == "watermark" { + if options.Kind == "watermark" { + if watermarkBounds.W == 0 { + watermarkBounds, err = measureWatermark(annotation.Appearance.Objects[0], fontData) + if err != nil { + return err + } + } content, err := currentSession.pageContent(index) if err != nil { return err @@ -1114,21 +1129,7 @@ func writeAnnotation(args []js.Value) (any, error) { if err != nil { return err } - annotation.Appearance.Boundary = fmt.Sprintf("0 0 %g %g", page.W, page.H) - base := annotation.Appearance.Objects[0] - annotation.Appearance.Objects = nil - for y := box.Y; y < page.H; y += box.H + 10 { - for x := box.X; x < page.W; x += box.W + 10 { - object := base - boundary := fmt.Sprintf("%g %g %g %g", x, y, box.W, box.H) - if object.Type == "TextObject" { - object.TextObject.Boundary = boundary - } else { - object.ImageObject.Boundary = boundary - } - annotation.Appearance.Objects = append(annotation.Appearance.Objects, object) - } - } + layoutWatermark(&annotation, watermarkBounds, options, page) } id, err = currentEditor.AddAnnotation(index, annotation) } @@ -1147,6 +1148,94 @@ func writeAnnotation(args []js.Value) (any, error) { return editorSelectionInfo{editorInfo: result.(editorInfo), SelectedIDs: ids}, nil } +// measureWatermark 复用标准渲染度量水印,临时文档保留尚未提交的字体引用 +// 入参: object 水印图元, fontData 字体字节 +// 返回: ofdgo.Box 实际绘制范围, error 错误信息 +func measureWatermark(object ofdgo.GraphicObject, fontData []byte) (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).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 错误信息