diff --git a/assets/webui/index.html b/assets/webui/index.html
index 95e5556..395497e 100644
--- a/assets/webui/index.html
+++ b/assets/webui/index.html
@@ -69,6 +69,11 @@
对象
+
-
-
pt
+
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css
index ecb157e..f5abf09 100644
--- a/assets/webui/ofdgo.css
+++ b/assets/webui/ofdgo.css
@@ -108,7 +108,8 @@ a:focus-visible {
grid-template-columns: repeat(5, max-content);
}
-.editor-group:has(#insertTextButton) {
+.editor-group:has(#insertTextButton),
+.editor-group:has(#textSize) {
grid-template-columns: repeat(2, max-content);
}
@@ -120,8 +121,7 @@ a:focus-visible {
border-right: 0;
}
-.editor-group > button,
-.editor-group > select {
+.editor-group > :not(span) {
grid-row: 1;
}
@@ -419,7 +419,8 @@ button:not(:disabled):hover {
.form-row input[type="text"],
.form-row input[type="number"],
.form-row input[type="file"],
-.form-row select {
+.form-row select,
+.text-size input {
width: 100%;
min-width: 0;
height: var(--control-height);
@@ -430,6 +431,33 @@ button:not(:disabled):hover {
color: #1f2428;
}
+.form-unit.text-size {
+ grid-template-columns: calc(2em + 22px) auto;
+ gap: 6px;
+}
+
+.color-input {
+ width: calc(2em + 22px);
+ height: var(--control-height);
+ padding: 3px;
+ border: 1px solid var(--line);
+ border-radius: 0;
+ background: #ffffff;
+ cursor: pointer;
+}
+
+.color-input::-webkit-color-swatch-wrapper {
+ padding: 0;
+}
+
+.color-input::-webkit-color-swatch {
+ border: 0;
+}
+
+.color-input::-moz-color-swatch {
+ border: 0;
+}
+
.form-row input[type="file"] {
padding: 0;
color: var(--text-muted);
diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js
index 8a625e1..15c7f14 100644
--- a/assets/webui/ofdgo.js
+++ b/assets/webui/ofdgo.js
@@ -89,6 +89,8 @@ const el = {
editTextButton: document.querySelector("#editTextButton"),
copyObjectButton: document.querySelector("#copyObjectButton"),
objectOrder: document.querySelector("#objectOrder"),
+ textSize: document.querySelector("#textSize"),
+ textColor: document.querySelector("#textColor"),
undoButton: document.querySelector("#undoButton"),
redoButton: document.querySelector("#redoButton"),
insertTextButton: document.querySelector("#insertTextButton"),
@@ -120,11 +122,13 @@ const el = {
insertTextRow: document.querySelector("#insertTextRow"),
insertFontRow: document.querySelector("#insertFontRow"),
insertSizeRow: document.querySelector("#insertSizeRow"),
+ insertColorRow: document.querySelector("#insertColorRow"),
insertImageRow: document.querySelector("#insertImageRow"),
insertText: document.querySelector("#insertText"),
insertFont: document.querySelector("#insertFont"),
insertFontAdd: document.querySelector("#insertFontAdd"),
insertSize: document.querySelector("#insertSize"),
+ insertColor: document.querySelector("#insertColor"),
insertImage: document.querySelector("#insertImage"),
insertStatus: document.querySelector("#insertStatus"),
insertCancel: document.querySelector("#insertCancel"),
@@ -249,6 +253,17 @@ el.editTextButton.addEventListener("click", () => {
openInsertPanel(true, canvasEditor.selected);
}
});
+el.textSize.addEventListener("change", () => changeTextStyle(false));
+el.textColor.addEventListener("change", () => changeTextStyle(true));
+el.textSize.addEventListener("keydown", (event) => {
+ if (event.key === "Enter" || event.key === "Escape") {
+ event.preventDefault();
+ if (event.key === "Escape") {
+ updateObjectControls(canvasEditor.selected, true);
+ }
+ el.textSize.blur();
+ }
+});
el.copyObjectButton.addEventListener("click", () => {
const item = canvasEditor.selected;
if (!item) {
@@ -628,11 +643,12 @@ async function openInsertPanel(text, item = null) {
if (item) {
state.insertFonts = [];
el.insertText.value = item.text;
- el.insertSize.value = Number((item.size * 72 / 25.4).toPrecision(12));
+ el.insertSize.value = textPoints(item.size);
+ el.insertColor.value = item.color;
}
el.insertPanel.setAttribute("aria-label", item ? "修改文字" : text ? "添加文字" : "添加图片");
el.insertSubmit.textContent = item ? "修改" : "添加";
- for (const [row, input] of [[el.insertTextRow, el.insertText], [el.insertFontRow, el.insertFont], [el.insertSizeRow, el.insertSize]]) {
+ for (const [row, input] of [[el.insertTextRow, el.insertText], [el.insertFontRow, el.insertFont], [el.insertSizeRow, el.insertSize], [el.insertColorRow, el.insertColor]]) {
row.hidden = !text;
input.disabled = !text;
}
@@ -690,16 +706,20 @@ async function insertObject(event) {
return;
}
const points = Number(el.insertSize.value);
- const size = item && points === Number((item.size * 72 / 25.4).toPrecision(12)) ? item.size : points * 25.4 / 72;
+ const size = item && points === textPoints(item.size) ? item.size : points * 25.4 / 72;
const doc = item
- ? await callWASM("ofdgoUpdateText", item.index, item.id, el.insertText.value, data, size)
- : text ? await callWASM("ofdgoInsertText", index, el.insertText.value, data, x, y, size)
+ ? await callWASM("ofdgoUpdateText", item.index, item.id, el.insertText.value, data, size, el.insertColor.value === item.color ? null : el.insertColor.value)
+ : text ? await callWASM("ofdgoInsertText", index, el.insertText.value, data, x, y, size, el.insertColor.value)
: await callWASM("ofdgoInsertImage", index, data, x, y, page.width - x * 2);
if (openSeq !== state.openSeq) {
return;
}
- setEditorInfo(doc);
el.insertPanel.close();
+ if (item && doc.revision === state.editorInfo?.revision) {
+ el.viewerPanel.focus({ preventScroll: true });
+ return;
+ }
+ setEditorInfo(doc);
canvasEditor.selectLast = !item;
state.selectObjects = true;
setPan(false);
@@ -723,6 +743,28 @@ async function insertObject(event) {
}
}
+function textPoints(size) {
+ return Number((size * 72 / 25.4).toPrecision(12));
+}
+
+async function changeTextStyle(color) {
+ const item = canvasEditor.selected;
+ if (!item || item.image || !state.ready || state.exporting) {
+ return;
+ }
+ if (!color && !el.textSize.checkValidity()) {
+ showError(new Error("字号无效"), false);
+ updateObjectControls(item, true);
+ return;
+ }
+ const size = color || Number(el.textSize.value) === textPoints(item.size) ? item.size : Number(el.textSize.value) * 25.4 / 72;
+ const fill = color && el.textColor.value !== item.color ? el.textColor.value : null;
+ if (size !== item.size || fill !== null) {
+ await changeDocument("ofdgoUpdateText", item, color ? null : item.text, null, size, fill);
+ }
+ updateObjectControls(canvasEditor.selected, true);
+}
+
async function changeDocument(name, item, ...args) {
if (!state.editing || document.body.hasAttribute("aria-busy")) {
return;
@@ -3796,10 +3838,17 @@ function updateEditorTools() {
el.redoButton.disabled = !state.editorInfo?.canRedo || !state.ready || state.exporting;
}
-function updateObjectControls(item) {
+function updateObjectControls(item, reset = false) {
const disabled = !item || !state.ready || state.exporting;
el.deleteObjectButton.disabled = el.copyObjectButton.disabled = disabled;
el.editTextButton.disabled = !item || item.image || !state.ready || state.exporting;
+ el.textSize.disabled = el.textColor.disabled = el.editTextButton.disabled;
+ if (reset || el.textSize.disabled || document.activeElement !== el.textSize) {
+ el.textSize.value = item && !item.image ? textPoints(item.size) : "";
+ }
+ if (reset || el.textColor.disabled || document.activeElement !== el.textColor) {
+ el.textColor.value = item && !item.image ? item.color : "#000000";
+ }
el.objectOrder.disabled = disabled || item.count <= 1;
for (const option of el.objectOrder.options) {
option.disabled = disabled || (["up", "top"].includes(option.value) ? item.order === item.count - 1 : item.order === 0);
diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js
index 6d93254..3150a33 100644
--- a/assets/webui/ofdgo_edit.js
+++ b/assets/webui/ofdgo_edit.js
@@ -119,13 +119,14 @@ export class CanvasEditor {
return;
}
const node = event.target.closest(".edit-object");
- if (!node) {
- this.select(null);
+ if (node) {
+ event.preventDefault();
+ }
+ this.viewer.focus({ preventScroll: true });
+ this.select(node ? this.nodes.get(node) : null);
+ if (!node || this.options.busy()) {
return;
}
- event.preventDefault();
- this.select(this.nodes.get(node));
- this.viewer.focus({ preventScroll: true });
const item = this.selected;
this.drag = {
item, pointerID: event.pointerId, corner: event.target.dataset.corner || "",
diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go
index 2c7b5c8..a76665b 100644
--- a/internal/webui/wasm.go
+++ b/internal/webui/wasm.go
@@ -20,6 +20,7 @@ import (
"bufio"
"bytes"
"context"
+ "encoding/hex"
"encoding/json"
"fmt"
"image"
@@ -611,6 +612,13 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
item := map[string]any{"id": id, "image": object.Type == "ImageObject", "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "count": len(layer.Objects)}
if object.Type == "TextObject" {
item["text"], item["font"], item["fontName"], item["size"] = textValues[id], object.TextObject.Font, fontNames[object.TextObject.Font], object.TextObject.Size
+ var r, g, b float64
+ if fill := object.TextObject.FillColor; fill != nil {
+ if _, err := fmt.Sscan(fill.Value, &r, &g, &b); err != nil {
+ return nil, err
+ }
+ }
+ item["color"] = fmt.Sprintf("#%02x%02x%02x", byte(r), byte(g), byte(b))
}
objects = append(objects, item)
}
@@ -794,8 +802,8 @@ func previewEditor(editor *ofdgo.Editor, annotations bool) (editorInfo, error) {
return editorSummary(), nil
}
-// updateText 修改文字内容、字体和字号,空字体数据沿用对象字体
-// 入参: args 页码、对象标识、内容、字体数据和字号
+// updateText 修改文字,null内容保留排版,null字体数据和颜色沿用对象属性
+// 入参: args 页码、对象标识、内容、字体数据、字号和颜色
// 返回: any 文档信息, error 错误信息
func updateText(args []js.Value) (any, error) {
if currentEditor == nil {
@@ -806,25 +814,60 @@ func updateText(args []js.Value) (any, error) {
if err != nil {
return nil, err
}
- fontID := object.TextObject.Font
- if !args[3].IsNull() {
- data, err := bytesFromJS(args[3])
- if err != nil {
- return nil, err
+ if object.Type != "TextObject" {
+ return nil, fmt.Errorf("object %q is not text", id)
+ }
+ if !args[2].IsNull() {
+ if !args[3].IsNull() {
+ data, err := bytesFromJS(args[3])
+ if err != nil {
+ return nil, err
+ }
+ object.TextObject.Font, err = currentEditor.AddFont(FontFile{Data: data}, 0)
+ if err != nil {
+ return nil, err
+ }
}
- fontID, err = currentEditor.AddFont(FontFile{Data: data}, 0)
- if err != nil {
+ object.TextObject.Size = args[4].Float()
+ if err := currentEditor.LayoutText(&object.TextObject, args[2].String()); err != nil {
return nil, err
}
}
- if err := currentEditor.UpdateText(page, id, args[2].String(), fontID, args[4].Float()); err != nil {
+ if !args[5].IsNull() {
+ if err := setTextColor(&object.TextObject, args[5].String()); err != nil {
+ return nil, err
+ }
+ }
+ revision := currentEditor.Revision()
+ if err := currentEditor.UpdateObject(page, id, object); err != nil {
return nil, err
}
+ if currentEditor.Revision() == revision {
+ return editorSummary(), nil
+ }
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
}
+// setTextColor 将浏览器RGB色值写入文字对象,保留颜色透明度
+// 入参: object 文字对象, value 十六进制色值
+// 返回: error 错误信息
+func setTextColor(object *ofdgo.TextObject, value string) error {
+ if len(value) != 7 || value[0] != '#' {
+ return fmt.Errorf("invalid RGB color %q", value)
+ }
+ rgb, err := hex.DecodeString(value[1:])
+ if err != nil {
+ return err
+ }
+ if object.FillColor == nil {
+ object.FillColor = &ofdgo.FillColor{}
+ }
+ object.FillColor.Value = fmt.Sprintf("%d %d %d", rgb[0], rgb[1], rgb[2])
+ return nil
+}
+
// insertText 使用选定字体创建文字对象
-// 入参: args 页码、内容、字体数据、横纵坐标和字号
+// 入参: args 页码、内容、字体数据、横纵坐标、字号和颜色
// 返回: any 文档信息, error 错误信息
func insertText(args []js.Value) (any, error) {
if currentEditor == nil {
@@ -849,7 +892,17 @@ func insertText(args []js.Value) (any, error) {
}
x, y := args[3].Float(), args[4].Float()
box = ofdgo.Box{X: x, Y: y, W: box.W - x, H: box.H - y}
- if _, err := currentEditor.AddText(page, box, args[1].String(), fontID, args[5].Float()); err != nil {
+ object := ofdgo.GraphicObject{Type: "TextObject", TextObject: ofdgo.TextObject{
+ Boundary: fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H),
+ Font: fontID, Size: args[5].Float(),
+ }}
+ if err := currentEditor.LayoutText(&object.TextObject, args[1].String()); err != nil {
+ return nil, err
+ }
+ if err := setTextColor(&object.TextObject, args[6].String()); err != nil {
+ return nil, err
+ }
+ if _, err := currentEditor.AddObject(page, object); err != nil {
return nil, err
}
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
diff --git a/ofdgo_editor.go b/ofdgo_editor.go
index 93c6a5b..fcfb704 100644
--- a/ofdgo_editor.go
+++ b/ofdgo_editor.go
@@ -621,7 +621,7 @@ func (e *Editor) AddText(page int, box Box, value, fontID string, size float64)
Boundary: fmt.Sprintf("%s %s %s %s", ofdNumber(box.X), ofdNumber(box.Y), ofdNumber(box.W), ofdNumber(box.H)),
Font: fontID, Size: size,
}}
- if err := e.layoutText(&object.TextObject, value); err != nil {
+ if err := e.LayoutText(&object.TextObject, value); err != nil {
return "", err
}
return e.AddObject(page, object)
@@ -641,7 +641,7 @@ func (e *Editor) UpdateText(page int, id, value, fontID string, size float64) er
return fmt.Errorf("object %q is not text", id)
}
object.TextObject.Font, object.TextObject.Size = fontID, size
- if err := e.layoutText(&object.TextObject, value); err != nil {
+ if err := e.LayoutText(&object.TextObject, value); err != nil {
return err
}
object, err = e.prepareObject(id, object)
@@ -652,10 +652,11 @@ func (e *Editor) UpdateText(page int, id, value, fontID string, size float64) er
return nil
}
-// layoutText 按嵌入字体度量设置横向单行文字的基线和字距
+// LayoutText 按嵌入字体度量重排横向单行文字,设置基线和字距,保留绘制属性
+// 不进行段落排版和复杂文字塑形,不修改文档,通过AddObject或UpdateObject提交
// 入参: obj 文字对象, value 原文
// 返回: error 错误信息
-func (e *Editor) layoutText(obj *TextObject, value string) error {
+func (e *Editor) LayoutText(obj *TextObject, value string) error {
if obj.ReadDirection != 0 || obj.CharDirection != 0 {
return fmt.Errorf("automatic text layout requires horizontal text")
}
@@ -663,6 +664,9 @@ func (e *Editor) layoutText(obj *TextObject, value string) error {
if !ok {
return fmt.Errorf("font resource %q not found", obj.Font)
}
+ if !finite(obj.Size) || obj.Size <= 0 || !finite(obj.HScale) || obj.HScale < 0 {
+ return fmt.Errorf("invalid text dimensions")
+ }
if !utf8.ValidString(value) || strings.ContainsAny(value, "\r\n\t") || value == "" {
return fmt.Errorf("text must be a nonempty UTF-8 line")
}