diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js
index d2de8bf..ddeeb4f 100644
--- a/assets/webui/ofdgo.js
+++ b/assets/webui/ofdgo.js
@@ -290,6 +290,14 @@ const el = {
objectOpacity: document.querySelector("#objectOpacity"),
objectGradientFields: document.querySelector("#objectGradientFields"),
gradientTarget: document.querySelector("#gradientTarget"),
+ objectBorderFields: document.querySelector("#objectBorderFields"),
+ borderEnabled: document.querySelector("#borderEnabled"),
+ borderWidth: document.querySelector("#borderWidth"),
+ borderColor: document.querySelector("#borderColor"),
+ borderColorOriginal: document.querySelector("#borderColorOriginal"),
+ borderHorizontal: document.querySelector("#borderHorizontal"),
+ borderVertical: document.querySelector("#borderVertical"),
+ patternTarget: document.querySelector("#patternTarget"),
gradientKind: document.querySelector("#gradientKind"),
gradientControls: document.querySelector("#gradientControls"),
gradientAngle: document.querySelector("#gradientAngle"),
@@ -994,6 +1002,14 @@ el.gradientKind.addEventListener("change", () => {
el.gradientAngle.disabled = el.gradientKind.value !== "linear";
for (const input of el.gradientStops.querySelectorAll("input")) input.disabled = el.gradientKind.value === "keep";
});
+el.gradientTarget.addEventListener("change", () => {
+ state.gradientForms[state.gradientTarget].form = gradientForm();
+ loadGradientForm(el.gradientTarget.value);
+});
+el.patternTarget.addEventListener("change", () => {
+ state.patternForms[state.patternTarget] = patternForm();
+ loadPatternForm(el.patternTarget.value);
+});
el.gradientAdd.addEventListener("click", () => addGradientStop(50, "#808080", 0));
el.objectDash.addEventListener("change", () => {
el.objectDashRow.hidden = el.objectDash.value !== "custom";
@@ -1442,21 +1458,47 @@ function openObjectStyle() {
el.copyStyleButton.disabled = items.length !== 1 || !canCopyStyle(items[0]);
el.pasteStyleButton.disabled = !state.styleClipboard || !items.every(item => item.type === state.styleClipboard && canCopyStyle(item));
const shared = (key, fallback) => items.every(item => (item[key] ?? fallback) === (items[0][key] ?? fallback)) ? items[0][key] ?? fallback : null;
- const pattern = items.length === 1 && !items[0].scoped ? items[0].fillPattern : null;
+ el.objectBorderFields.hidden = items.length !== 1 || items[0].scoped || items[0].type !== "ImageObject";
+ const border = items[0].borderStyle ? JSON.parse(items[0].borderStyle) : null;
+ el.borderEnabled.checked = Boolean(border);
+ el.borderWidth.value = String(border?.LineWidth ?? .353);
+ el.borderHorizontal.value = String(border?.HorizonalCornerRadius ?? 0);
+ el.borderVertical.value = String(border?.VerticalCornerRadius ?? 0);
+ el.borderColor.value = items[0].borderColor || "#000000";
+ el.borderColor.disabled = Boolean(border && !items[0].borderColor);
+ el.borderColor.hidden = el.borderColor.disabled;
+ el.borderColorOriginal.hidden = !el.borderColor.disabled;
+ state.borderOriginal = {
+ Enabled: el.borderEnabled.checked, LineWidth: el.borderWidth.value,
+ HorizontalRadius: el.borderHorizontal.value, VerticalRadius: el.borderVertical.value,
+ Color: el.borderColor.value
+ };
el.objectGradientFields.hidden = items.length !== 1 || items[0].scoped || items[0].type !== "PathObject" || !items[0].paintSize || !canEditObject(items[0], "paint");
- el.gradientKind.value = "keep";
- el.gradientTarget.value = "fill";
- el.gradientControls.hidden = true;
- el.gradientAngle.value = "0";
- el.gradientAngle.disabled = true;
- el.gradientStops.replaceChildren();
- addGradientStop(0, "#000000", 0);
- addGradientStop(100, "#ffffff", 0);
- el.objectPatternFields.hidden = !pattern;
- for (const [input, key] of [[el.patternWidth, "width"], [el.patternHeight, "height"], [el.patternXStep, "xStep"], [el.patternYStep, "yStep"], [el.patternCTM, "ctm"]]) {
- input.value = pattern?.[key] ?? "";
- input.disabled = !pattern;
+ state.gradientForms = {};
+ for (const target of ["fill", "stroke"]) {
+ const gradient = items[0][`${target}Gradient`], base = gradient ? JSON.parse(gradient.paint) : null;
+ const node = base?.AxialShd || base?.RadialShd;
+ const start = node?.StartPoint.split(/\s+/).map(Number), end = node?.EndPoint.split(/\s+/).map(Number);
+ const form = {
+ kind: base?.AxialShd ? "linear" : base?.RadialShd ? "radial" : "keep",
+ angle: String(base?.AxialShd ? Math.atan2(end[1] - start[1], end[0] - start[0]) * 180 / Math.PI : 0),
+ stops: gradient ? gradient.stops.map((stop, i) => ({ position: String(stop.position), color: stop.color, transparency: String(stop.transparency), source: node.Segment[i] }))
+ : [{ position: "0", color: "#000000", transparency: "0" }, { position: "100", color: "#ffffff", transparency: "0" }]
+ };
+ state.gradientForms[target] = { base, form, original: JSON.stringify(form) };
}
+ el.gradientTarget.value = "fill";
+ loadGradientForm("fill");
+ state.patternForms = {};
+ for (const target of ["fill", "stroke"]) {
+ const pattern = items.length === 1 && !items[0].scoped ? items[0][`${target}Pattern`] : null;
+ if (pattern) state.patternForms[target] = Object.fromEntries(Object.entries(pattern).map(([key, value]) => [key, String(value)]));
+ }
+ state.patternOriginal = JSON.stringify(state.patternForms);
+ el.objectPatternFields.hidden = !Object.keys(state.patternForms).length;
+ el.patternTarget.disabled = Object.keys(state.patternForms).length < 2;
+ el.patternTarget.value = state.patternForms.fill ? "fill" : "stroke";
+ loadPatternForm(el.patternTarget.value);
const alpha = items.some(item => item.alphaMixed) ? null : shared("alpha", 255), dash = shared("dashPattern", "");
el.objectOpacity.value = alpha === null ? "" : String(Math.round((1 - alpha / 255) * 10000) / 100);
el.objectOpacity.placeholder = alpha === null ? "混合" : "";
@@ -1468,31 +1510,69 @@ function openObjectStyle() {
el.objectDashOffset.value = shared("dashOffset", 0) ?? "";
el.objectCap.value = shared("cap", "") || (shared("cap", "") === null ? "mixed" : "Butt");
el.objectJoin.value = shared("join", "") || (shared("join", "") === null ? "mixed" : "Miter");
- state.styleOriginal = Object.fromEntries(["objectOpacity", "objectDash", "objectDashPattern", "objectDashOffset", "objectCap", "objectJoin", "patternWidth", "patternHeight", "patternXStep", "patternYStep", "patternCTM"].map(key => [key, el[key].value]));
+ state.styleOriginal = Object.fromEntries(["objectOpacity", "objectDash", "objectDashPattern", "objectDashOffset", "objectCap", "objectJoin"].map(key => [key, el[key].value]));
el.objectStyleStatus.textContent = "";
el.objectStylePanel.showModal();
}
function readObjectStyle() {
const style = {}, changed = key => el[key].value !== state.styleOriginal[key];
- if (!el.objectGradientFields.hidden && el.gradientKind.value !== "keep") {
- const [width, height] = canvasEditor.selected.paintSize;
- const Segment = [...el.gradientStops.children].map(row => ({Position: Number(row.children[1].value) / 100, Color: {Value: row.children[0].value, Alpha: Math.round(255 * (1 - Number(row.children[2].value) / 100))}})).sort((a, b) => a.Position - b.Position);
- const angle = Number(el.gradientAngle.value) * Math.PI / 180, x = Math.cos(angle), y = Math.sin(angle);
- const length = Math.abs(width * x) + Math.abs(height * y);
- const paint = el.gradientKind.value === "linear"
- ? {AxialShd: {StartPoint: `${width / 2 - x * length / 2} ${height / 2 - y * length / 2}`, EndPoint: `${width / 2 + x * length / 2} ${height / 2 + y * length / 2}`, Extend: "3", Segment}}
- : {RadialShd: {StartPoint: `${width / 2} ${height / 2}`, EndPoint: `${width / 2} ${height / 2}`, EndRadius: Math.max(width, height) / 2, Extend: "3", Segment}};
- const target = el.gradientTarget.value;
- style[`${target}Paint`] = JSON.stringify(paint);
- style[target] = true;
+ if (!el.objectBorderFields.hidden) {
+ const border = {};
+ if (el.borderEnabled.checked !== state.borderOriginal.Enabled) border.Enabled = el.borderEnabled.checked;
+ if (el.borderEnabled.checked) {
+ for (const [input, key] of [[el.borderWidth, "LineWidth"], [el.borderHorizontal, "HorizontalRadius"], [el.borderVertical, "VerticalRadius"]]) {
+ if (input.value !== state.borderOriginal[key] || !state.borderOriginal.Enabled) border[key] = Number(input.value);
+ }
+ if (!el.borderColor.disabled && (el.borderColor.value !== state.borderOriginal.Color || !state.borderOriginal.Enabled)) {
+ border.Color = { Value: el.borderColor.value };
+ }
+ }
+ if (Object.keys(border).length) style.borderStyle = JSON.stringify(border);
+ }
+ if (!el.objectGradientFields.hidden) {
+ state.gradientForms[state.gradientTarget].form = gradientForm();
+ for (const target of ["fill", "stroke"]) {
+ const { base, form, original } = state.gradientForms[target];
+ if (form.kind === "keep" || JSON.stringify(form) === original) continue;
+ const [width, height] = canvasEditor.selected.paintSize;
+ const previous = JSON.parse(original);
+ const Segment = form.stops.map(stop => {
+ const old = previous.stops.find(entry => entry.source && JSON.stringify(entry.source) === JSON.stringify(stop.source));
+ const Color = old && old.color === stop.color ? { ...stop.source.Color } : { Value: stop.color };
+ if (!old || old.transparency !== stop.transparency || old.color !== stop.color) Color.Alpha = Math.round(255 * (1 - Number(stop.transparency) / 100));
+ return { Position: Number(stop.position) / 100, Color };
+ }).sort((a, b) => a.Position - b.Position);
+ const angle = Number(form.angle) * Math.PI / 180, x = Math.cos(angle), y = Math.sin(angle);
+ const length = Math.abs(width * x) + Math.abs(height * y);
+ let paint = form.kind === "linear"
+ ? { AxialShd: { StartPoint: `${width / 2 - x * length / 2} ${height / 2 - y * length / 2}`, EndPoint: `${width / 2 + x * length / 2} ${height / 2 + y * length / 2}`, Extend: "3", Segment } }
+ : { RadialShd: { StartPoint: `${width / 2} ${height / 2}`, EndPoint: `${width / 2} ${height / 2}`, EndRadius: Math.max(width, height) / 2, Extend: "3", Segment } };
+ if (base && form.kind === previous.kind) {
+ paint = JSON.parse(JSON.stringify(base));
+ const node = paint.AxialShd || paint.RadialShd;
+ node.Segment = Segment;
+ if (paint.AxialShd && form.angle !== previous.angle) {
+ const start = node.StartPoint.split(/\s+/).map(Number), end = node.EndPoint.split(/\s+/).map(Number);
+ const half = Math.hypot(end[0] - start[0], end[1] - start[1]) / 2, cx = (start[0] + end[0]) / 2, cy = (start[1] + end[1]) / 2;
+ node.StartPoint = `${cx - x * half} ${cy - y * half}`;
+ node.EndPoint = `${cx + x * half} ${cy + y * half}`;
+ }
+ }
+ style[`${target}Paint`] = JSON.stringify(paint);
+ style[target] = true;
+ }
}
if (!el.objectPatternFields.hidden) {
- const pattern = {};
- for (const [input, key] of [["patternWidth", "Width"], ["patternHeight", "Height"], ["patternXStep", "XStep"], ["patternYStep", "YStep"], ["patternCTM", "CTM"]]) {
- if (changed(input)) pattern[key] = key === "CTM" ? el[input].value.trim() : Number(el[input].value);
+ state.patternForms[state.patternTarget] = patternForm();
+ const original = JSON.parse(state.patternOriginal);
+ for (const [target, form] of Object.entries(state.patternForms)) {
+ const pattern = {};
+ for (const [key, field] of [["width", "Width"], ["height", "Height"], ["xStep", "XStep"], ["yStep", "YStep"], ["ctm", "CTM"]]) {
+ if (form[key] !== original[target][key]) pattern[field] = key === "ctm" ? form[key].trim() : Number(form[key]);
+ }
+ if (Object.keys(pattern).length) style[`${target}PatternStyle`] = JSON.stringify(pattern);
}
- if (Object.keys(pattern).length) style.patternStyle = JSON.stringify(pattern);
}
if (changed("objectOpacity") && el.objectOpacity.value !== "") style.alpha = Math.round((1 - Number(el.objectOpacity.value) / 100) * 255);
if (!el.objectStrokeFields.hidden) {
@@ -1507,8 +1587,40 @@ function readObjectStyle() {
return style;
}
-function addGradientStop(position, color, transparency) {
+function gradientForm() {
+ return {
+ kind: el.gradientKind.value, angle: el.gradientAngle.value,
+ stops: [...el.gradientStops.children].map(row => ({ position: row.children[1].value, color: row.children[0].value, transparency: row.children[2].value, source: row.paintSource }))
+ };
+}
+
+function loadGradientForm(target) {
+ state.gradientTarget = target;
+ const form = state.gradientForms[target].form;
+ el.gradientKind.value = form.kind;
+ el.gradientAngle.value = form.angle;
+ el.gradientControls.hidden = form.kind === "keep";
+ el.gradientAngle.disabled = form.kind !== "linear";
+ el.gradientStops.replaceChildren();
+ for (const stop of form.stops) addGradientStop(stop.position, stop.color, stop.transparency, stop.source);
+}
+
+function patternForm() {
+ return { width: el.patternWidth.value, height: el.patternHeight.value, xStep: el.patternXStep.value, yStep: el.patternYStep.value, ctm: el.patternCTM.value };
+}
+
+function loadPatternForm(target) {
+ state.patternTarget = target;
+ const pattern = state.patternForms[target];
+ for (const [input, key] of [[el.patternWidth, "width"], [el.patternHeight, "height"], [el.patternXStep, "xStep"], [el.patternYStep, "yStep"], [el.patternCTM, "ctm"]]) {
+ input.value = pattern?.[key] ?? "";
+ input.disabled = !pattern;
+ }
+}
+
+function addGradientStop(position, color, transparency, source) {
const row = document.createElement("div");
+ row.paintSource = source;
row.className = "gradient-stop-row";
for (const [type, value, label] of [["color", color, "色标颜色"], ["number", position, "色标位置"], ["number", transparency, "色标透明度"]]) {
const input = document.createElement("input");
diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js
index 0f12cf3..1846f56 100644
--- a/assets/webui/ofdgo_edit.js
+++ b/assets/webui/ofdgo_edit.js
@@ -1195,7 +1195,8 @@ export class CanvasEditor {
input.append(paragraph);
}
const left = item.leftIndent || 0, right = item.rightIndent || 0;
- const frame = item.textFrame || { width: item.width, height: item.height, matrix: [1, 0, 0, 1, item.x, item.y] };
+ const frame = { ...(item.textFrame || { width: item.width, height: item.height, matrix: [1, 0, 0, 1, item.x, item.y] }) };
+ if (item.wrap && item.paragraphWidth != null) frame.width = item.paragraphWidth;
const [a, b, c, d, e, f] = frame.matrix;
const autoSize = !source && !item.wrap && (!item.align || item.align === "left");
Object.assign(input.style, {
diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go
index 616905c..dffd919 100644
--- a/internal/webui/wasm.go
+++ b/internal/webui/wasm.go
@@ -937,7 +937,7 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
local := inverse.TransformBox(box)
width := math.Max(object.TextObject.Size, local.X+local.W)
if layout.Wrap {
- width = frame.W
+ item["paragraphWidth"] = frame.W
}
frame = ofdgo.Box{W: width, H: math.Max(object.TextObject.Size, local.Y+local.H)}
item["textFrame"] = map[string]any{"width": frame.W, "height": frame.H, "matrix": editorMatrix(matrix)}
@@ -1712,10 +1712,11 @@ func editorCapabilities(capability ofdgo.ObjectCapabilities, kind string) map[st
// 入参: item 前端对象, object 有效样式, paint 是否可改色, scale 描边页面倍率
func editorAppearance(item map[string]any, object ofdgo.GraphicObject, paint bool, scale float64) {
var alpha *int
- var fill *ofdgo.FillColor
+ var fill, stroke *ofdgo.FillColor
switch object.Type {
case "TextObject":
fill = object.TextObject.FillColor
+ stroke = (*ofdgo.FillColor)(object.TextObject.StrokeColor)
alpha = object.TextObject.Alpha
if paint {
item["color"] = editorColorHex(object.TextObject.FillColor)
@@ -1723,9 +1724,17 @@ func editorAppearance(item map[string]any, object ofdgo.GraphicObject, paint boo
case "ImageObject":
alpha = object.ImageObject.Alpha
item["imageBorder"] = object.ImageObject.Border != nil
+ item["borderColor"] = "#000000"
+ if border := object.ImageObject.Border; border != nil {
+ item["borderColor"] = editorColorHex((*ofdgo.FillColor)(border.BorderColor))
+ }
+ if data, err := json.Marshal(object.ImageObject.Border); err == nil {
+ item["borderStyle"] = string(data)
+ }
case "PathObject":
path := object.PathObject
fill = path.FillColor
+ stroke = (*ofdgo.FillColor)(path.StrokeColor)
if box, err := ofdgo.ParseBox(path.Boundary); err == nil {
item["paintSize"] = []any{box.W, box.H}
}
@@ -1746,9 +1755,34 @@ func editorAppearance(item map[string]any, object ofdgo.GraphicObject, paint boo
if alpha != nil {
item["alpha"] = *alpha
}
- if fill != nil && fill.Pattern != nil {
- p := fill.Pattern
- item["fillPattern"] = map[string]any{"width": p.Width, "height": p.Height, "xStep": p.XStep, "yStep": p.YStep, "ctm": p.CTM}
+ for key, paint := range map[string]*ofdgo.FillColor{"fill": fill, "stroke": stroke} {
+ if paint == nil {
+ continue
+ }
+ if p := paint.Pattern; p != nil {
+ item[key+"Pattern"] = map[string]any{"width": p.Width, "height": p.Height, "xStep": p.XStep, "yStep": p.YStep, "ctm": p.CTM}
+ }
+ var segments []ofdgo.ShdSegment
+ if paint.AxialShd != nil {
+ segments = paint.AxialShd.Segment
+ } else if paint.RadialShd != nil {
+ segments = paint.RadialShd.Segment
+ }
+ if len(segments) == 0 {
+ continue
+ }
+ stops, err := currentEditor.GradientStops(segments, nil)
+ if err != nil {
+ continue
+ }
+ values := make([]any, len(stops))
+ for i, stop := range stops {
+ c := segments[i].Color
+ values[i] = map[string]any{"position": stop.Offset * 100, "color": editorColorHex(&ofdgo.FillColor{Value: c.Value, Index: c.Index, ColorSpace: c.ColorSpace}), "transparency": (1 - float64(stop.Color.A)/255) * 100}
+ }
+ if data, err := json.Marshal(paint); err == nil {
+ item[key+"Gradient"] = map[string]any{"paint": string(data), "stops": values}
+ }
}
}
@@ -1827,7 +1861,7 @@ func compositeObjects(args []js.Value) (any, error) {
if err != nil {
return nil, err
}
- width = frame.W
+ item["paragraphWidth"] = frame.W
}
frame := ofdgo.Box{W: width, H: math.Max(object.Size, local.Y+local.H)}
item["textFrame"] = map[string]any{"width": frame.W, "height": frame.H, "matrix": editorMatrix(member.Matrix)}
@@ -2904,12 +2938,22 @@ func changeOutline(args []js.Value) (any, error) {
// 返回: any 文档信息, error 错误信息
func styleObjects(args []js.Value) (any, error) {
style := objectStyle(args[2])
- var pattern *ofdgo.PatternStyle
- if field := args[2].Get("patternStyle"); !field.IsUndefined() {
- if err := json.Unmarshal([]byte(field.String()), &pattern); err != nil {
+ var border *ofdgo.ImageBorderStyle
+ if field := args[2].Get("borderStyle"); !field.IsUndefined() {
+ if err := json.Unmarshal([]byte(field.String()), &border); err != nil {
return nil, err
}
}
+ patterns := make(map[bool]*ofdgo.PatternStyle)
+ for key, stroke := range map[string]bool{"fillPatternStyle": false, "strokePatternStyle": true} {
+ if field := args[2].Get(key); !field.IsUndefined() {
+ var pattern ofdgo.PatternStyle
+ if err := json.Unmarshal([]byte(field.String()), &pattern); err != nil {
+ return nil, err
+ }
+ patterns[stroke] = &pattern
+ }
+ }
if field := args[2].Get("fillPaint"); !field.IsUndefined() {
if err := json.Unmarshal([]byte(field.String()), &style.FillColor); err != nil {
return nil, err
@@ -2923,6 +2967,14 @@ func styleObjects(args []js.Value) (any, error) {
return changeObjects(func() error {
return currentEditor.Transaction(func(editor *ofdgo.Editor) error {
page, ids := args[0].Int(), stringsFromJS(args[1])
+ if border != nil {
+ if err := resolveStylePaint((*ofdgo.FillColor)(border.Color)); err != nil {
+ return err
+ }
+ if err := editor.StyleImageBorders(page, ids, *border); err != nil {
+ return err
+ }
+ }
for _, paint := range []*ofdgo.FillColor{style.FillColor, (*ofdgo.FillColor)(style.StrokeColor)} {
if err := resolveStylePaint(paint); err != nil {
return err
@@ -2931,8 +2983,10 @@ func styleObjects(args []js.Value) (any, error) {
if err := editor.StyleObjects(page, ids, style); err != nil {
return err
}
- if pattern != nil {
- return editor.StylePatterns(page, ids, false, *pattern)
+ for stroke, pattern := range patterns {
+ if err := editor.StylePatterns(page, ids, stroke, *pattern); err != nil {
+ return err
+ }
}
return nil
})
@@ -2946,6 +3000,9 @@ func resolveStylePaint(paint *ofdgo.FillColor) error {
if paint == nil {
return nil
}
+ if strings.HasPrefix(paint.Value, "#") {
+ return setEditorColor(paint, paint.Value)
+ }
var segments []ofdgo.ShdSegment
if paint.AxialShd != nil {
segments = paint.AxialShd.Segment
diff --git a/ofdgo_editor.go b/ofdgo_editor.go
index dc14ad1..fd13bf9 100644
--- a/ofdgo_editor.go
+++ b/ofdgo_editor.go
@@ -23,6 +23,7 @@ import (
"fmt"
"image"
"io/fs"
+ "maps"
"math"
"reflect"
"slices"
@@ -59,6 +60,7 @@ type Editor struct {
serial uint64
source *editorSource
origins map[string]*editorObjectOrigin
+ removedPages map[string]bool
outlines []byte
encryption *encryptionState
}
@@ -271,89 +273,16 @@ func (e *Editor) CopyPages(indexes []int) ([]int, error) {
return nil, nil
}
at := len(e.pages)
- if e.source != nil {
- source, err := e.Reader()
- if err != nil {
- return nil, err
- }
- if _, err := e.importPages(source, indexes, at, true, PageImportOptions{}); err != nil {
- return nil, err
- }
- result := make([]int, len(indexes))
- for i := range result {
- result[i] = at + i
- }
- return result, nil
+ source, err := e.Reader()
+ if err != nil {
+ return nil, err
}
- pages := make([]PageContent, len(indexes))
- for n, index := range indexes {
- source := &e.pages[index]
- page := &pages[n]
- *page = PageContent{Area: source.Area, Content: Content{Layer: make([]Layer, len(source.Content.Layer))}}
- for i, layer := range source.Content.Layer {
- page.Content.Layer[i] = Layer{Type: layer.Type, Objects: make([]GraphicObject, len(layer.Objects))}
- for j, object := range layer.Objects {
- copy, err := cloneEditorObject(object)
- if err != nil {
- return nil, err
- }
- page.Content.Layer[i].Objects[j] = copy
- }
- }
+ if _, err := e.importPages(source, indexes, at, true, PageImportOptions{}); err != nil {
+ return nil, err
}
- result := make([]int, len(pages))
- maximum := e.maxID
- origins := make(map[string]*editorObjectOrigin)
- for n := range pages {
- page := &pages[n]
- maximum++
- page.ID = strconv.Itoa(maximum)
- for i := range page.Content.Layer {
- layer := &page.Content.Layer[i]
- maximum++
- layer.ID = strconv.Itoa(maximum)
- for j := range layer.Objects {
- object := &layer.Objects[j]
- before := *object
- maximum++
- id := strconv.Itoa(maximum)
- switch object.Type {
- case "TextObject":
- object.TextObject.ID = id
- case "PathObject":
- object.PathObject.ID = id
- case "ImageObject":
- object.ImageObject.ID = id
- case "CompositeObject", "CompositeGraphicUnit":
- object.CompositeGraphicUnit.ID = id
- }
- copied, origin, err := e.copyObjectOrigin(before, *object, &maximum)
- if err != nil {
- return nil, err
- }
- *object = copied
- if origin != nil {
- origins[id] = origin
- }
- }
- }
- result[n] = at + n
- }
- e.maxID = maximum
- for id, origin := range origins {
- e.setObjectOrigin(id, origin)
- }
- e.pages = append(e.pages, pages...)
- if change := e.recordChange(); change != nil {
- for i := range pages {
- pages[i] = copyEditorPage(pages[i])
- }
- change.undo = func(e *Editor) { e.pages = e.pages[:at] }
- change.redo = func(e *Editor) {
- for _, page := range pages {
- e.pages = append(e.pages, copyEditorPage(page))
- }
- }
+ result := make([]int, len(indexes))
+ for i := range result {
+ result[i] = at + i
}
return result, nil
}
@@ -386,6 +315,13 @@ func (e *Editor) DeletePages(indexes []int) error {
saved[i] = copyEditorPage(*page)
}
remove := func(e *Editor) {
+ e.removedPages = maps.Clone(e.removedPages)
+ if e.removedPages == nil {
+ e.removedPages = make(map[string]bool)
+ }
+ for _, page := range saved {
+ e.removedPages[page.ID] = true
+ }
kept, next := e.pages[:0], 0
for index, page := range e.pages {
if next < len(indexes) && index == indexes[next] {
@@ -397,8 +333,10 @@ func (e *Editor) DeletePages(indexes []int) error {
clear(e.pages[len(kept):])
e.pages = kept
}
+ previousRemoved := e.removedPages
if change := e.recordChange(); change != nil {
change.undo = func(e *Editor) {
+ e.removedPages = previousRemoved
pages := make([]PageContent, len(e.pages)+len(saved))
kept, removed := 0, 0
for index := range pages {
diff --git a/ofdgo_editor_color.go b/ofdgo_editor_color.go
index 3397390..047e9d1 100644
--- a/ofdgo_editor_color.go
+++ b/ofdgo_editor_color.go
@@ -32,6 +32,22 @@ type PatternStyle struct {
CTM *string
}
+// GradientStops 解析当前文档的渐变分段,保留顺序并合并透明度
+// 入参: segments 渐变分段, alpha 外层透明度
+// 返回: []ColorStop 后端无关的色标, error 颜色解析错误
+func (e *Editor) GradientStops(segments []ShdSegment, alpha *int) ([]ColorStop, error) {
+ positions := gradientPositions(segments)
+ stops := make([]ColorStop, len(segments))
+ for i, segment := range segments {
+ c, err := e.Color(&FillColor{Value: segment.Color.Value, Index: segment.Color.Index, ColorSpace: segment.Color.ColorSpace, Alpha: mergeAlpha(segment.Color.Alpha, alpha)})
+ if err != nil {
+ return nil, err
+ }
+ stops[i] = ColorStop{Offset: positions[i], Color: colorToRGBA(c)}
+ }
+ return stops, nil
+}
+
// StylePatterns 修改已有图案布局,保留单元内容、资源引用和未知扩展
// 入参: page 页面索引, ids 对象标识, stroke 是否修改描边, style 图案布局增量
// 返回: error 错误信息
diff --git a/ofdgo_editor_history.go b/ofdgo_editor_history.go
index 7be5bcf..eb72f32 100644
--- a/ofdgo_editor_history.go
+++ b/ofdgo_editor_history.go
@@ -61,6 +61,7 @@ func (e *Editor) transactionSnapshot() Editor {
next.resources = slices.Clone(e.resources)
next.fonts, next.images, next.resourceID = maps.Clone(e.fonts), maps.Clone(e.images), maps.Clone(e.resourceID)
next.origins = maps.Clone(e.origins)
+ next.removedPages = maps.Clone(e.removedPages)
next.fontMetrics = maps.Clone(e.fontMetrics)
if e.source != nil {
source := *e.source
diff --git a/ofdgo_editor_import.go b/ofdgo_editor_import.go
index 98035ba..e1f0a90 100644
--- a/ofdgo_editor_import.go
+++ b/ofdgo_editor_import.go
@@ -737,7 +737,7 @@ func (m *editorPageImport) encode(entry editorImportEntry, node *editorXML) ([]b
for _, bookmark := range m.doc.Bookmarks.Bookmark {
bookmarks[bookmark.Name] = bookmark.Dest
}
- if dest := gotoDest(&action, bookmarks); dest == nil || !m.pages[dest.PageID] && !m.copyPage {
+ if dest := gotoDest(&action, bookmarks); !m.copyPage && (dest == nil || !m.pages[dest.PageID]) {
return nil, nil
}
}
@@ -759,7 +759,11 @@ func (m *editorPageImport) encode(entry editorImportEntry, node *editorXML) ([]b
value = name + ":" + value
}
value = m.id(value)
- case "Font", "ResourceID", "Substitution", "ImageMask", "Relative", "DrawParam", "ColorSpace", "Thumbnail", "TemplateID", "PageID", "PageRef", "RefId":
+ case "PageID":
+ if !m.copyPage || m.pages[value] {
+ value, err = m.reference(value)
+ }
+ case "Font", "ResourceID", "Substitution", "ImageMask", "Relative", "DrawParam", "ColorSpace", "Thumbnail", "TemplateID", "PageRef", "RefId":
value, err = m.reference(value)
case "BaseLoc", "FileRef":
loc, resolveErr := m.resolve(entry.name, "", value)
diff --git a/ofdgo_editor_link.go b/ofdgo_editor_link.go
index 06fb213..4cb4a05 100644
--- a/ofdgo_editor_link.go
+++ b/ofdgo_editor_link.go
@@ -27,6 +27,62 @@ type AnnotationLink struct {
Dest *Dest
}
+// SetObjectLink 修改对象的单个点击链接,保留其他事件和点击区域,nil移除链接
+// 入参: page 页面索引, ids 对象标识, link 链接目标,不检查可达性
+// 返回: error 错误信息
+func (e *Editor) SetObjectLink(page int, ids []string, link *AnnotationLink) error {
+ if link != nil {
+ if _, err := annotationLinkXML(*link); err != nil {
+ return err
+ }
+ }
+ objects, _, err := e.selectedObjects(page, ids)
+ if err != nil {
+ return err
+ }
+ for i := range objects {
+ var actions *[]Action
+ switch objects[i].Type {
+ case "TextObject":
+ actions = &objects[i].TextObject.Actions
+ case "PathObject":
+ actions = &objects[i].PathObject.Actions
+ case "ImageObject":
+ actions = &objects[i].ImageObject.Actions
+ case "CompositeObject", "CompositeGraphicUnit":
+ actions = &objects[i].CompositeGraphicUnit.Actions
+ default:
+ return fmt.Errorf("object %q cannot contain links", ids[i])
+ }
+ *actions = cloneEditorData(*actions)
+ index := -1
+ for j, action := range *actions {
+ if action.Event == "CLICK" && (action.URI != nil || action.Goto != nil) {
+ if index >= 0 {
+ return fmt.Errorf("object %q contains multiple click links", ids[i])
+ }
+ index = j
+ }
+ }
+ if link == nil {
+ if index >= 0 {
+ *actions = slices.Delete(*actions, index, index+1)
+ }
+ continue
+ }
+ if index < 0 {
+ index = len(*actions)
+ *actions = append(*actions, Action{Event: "CLICK"})
+ }
+ action := &(*actions)[index]
+ action.URI, action.Goto = cloneEditorData(link.URI), nil
+ if link.Dest != nil {
+ action.Goto = &Goto{Dest: cloneEditorData(link.Dest)}
+ }
+ }
+ return e.updateObjects(page, objects, true)
+}
+
// SetObjectActions 原子替换普通对象的链接动作,空列表移除动作
// 入参: page 页面索引, ids 对象标识, actions 链接动作
// 返回: error 错误信息
diff --git a/ofdgo_editor_package.go b/ofdgo_editor_package.go
index 8e78548..b1742d5 100644
--- a/ofdgo_editor_package.go
+++ b/ofdgo_editor_package.go
@@ -214,7 +214,7 @@ func (e *Editor) sourceParts(progress editorProgress) (map[string][]byte, error)
}
parts[name] = updated
}
- if originalCount != len(source.pages) || len(source.annotationPages) != 0 {
+ if originalCount != len(source.pages) || len(source.annotationPages) != 0 || len(e.removedPages) != 0 {
if err := e.prunePageReferences(parts); err != nil {
return nil, err
}
diff --git a/ofdgo_editor_pages.go b/ofdgo_editor_pages.go
index aeef547..4b562a5 100644
--- a/ofdgo_editor_pages.go
+++ b/ofdgo_editor_pages.go
@@ -16,6 +16,7 @@ package ofdgo
import (
"fmt"
+ "maps"
"strings"
)
@@ -24,7 +25,10 @@ import (
// 入参: parts 输出覆盖条目
// 返回: error 读取或解析错误
func (e *Editor) prunePageReferences(parts map[string][]byte) error {
- removed := make(map[string]bool)
+ removed := maps.Clone(e.removedPages)
+ if removed == nil {
+ removed = make(map[string]bool)
+ }
for id := range e.source.pages {
removed[id] = true
}
@@ -154,6 +158,36 @@ func (e *Editor) prunePageReferences(parts map[string][]byte) error {
return nil
}
+// pruneCreatedPageReferences 清理创作文档中被明确删除页面的直接跳转,不检查其他目标是否可达
+// 入参: data 创作XML, removed 已删除页面标识
+// 返回: []byte 更新后的XML, error 解析错误
+func pruneCreatedPageReferences(data []byte, removed map[string]bool) ([]byte, error) {
+ root, err := parseEditorXML(data)
+ if err != nil {
+ return nil, err
+ }
+ var patches []editorXMLPatch
+ var walk func(*editorXML)
+ walk = func(node *editorXML) {
+ if node.name.Space != ofdNamespace {
+ return
+ }
+ if node.name.Local == "Action" {
+ if target := node.child("Goto"); target != nil {
+ if dest := target.child("Dest"); dest != nil && removed[dest.attr("PageID")] {
+ patches = append(patches, editorXMLPatch{start: node.start, end: node.end})
+ return
+ }
+ }
+ }
+ for _, child := range node.children {
+ walk(child)
+ }
+ }
+ walk(root)
+ return editorPatchXML(data, patches), nil
+}
+
// editorPageLocation 解析标准XML引用,优先使用所在文件的相对路径
// 入参: reader 原包, parts 覆盖条目, name 所在XML, value 路径
// 返回: string 包内路径, error 路径不存在
diff --git a/ofdgo_editor_style.go b/ofdgo_editor_style.go
index 756dd88..0f92799 100644
--- a/ofdgo_editor_style.go
+++ b/ofdgo_editor_style.go
@@ -21,6 +21,30 @@ import (
"strings"
)
+// ObjectStyle 对象外观,nil字段保持原值,颜色的Alpha为nil时保留原颜色透明度
+// StyleObjects使用对象坐标毫米,StyleCompositeObjects的LineWidth使用页面毫米
+type ObjectStyle struct {
+ Alpha *int
+ Fill *bool
+ Stroke *bool
+ FillColor *FillColor
+ StrokeColor *StrokeColor
+ LineWidth *float64
+ DashPattern *string
+ DashOffset *float64
+ Cap *string
+ Join *string
+}
+
+// ImageBorderStyle 图片边框增量,nil字段及未指定的颜色透明度保留原值,尺寸单位为毫米
+type ImageBorderStyle struct {
+ Enabled *bool
+ LineWidth *float64
+ HorizontalRadius *float64
+ VerticalRadius *float64
+ Color *StrokeColor
+}
+
// AddDrawParam 注册绘制参数,保留继承引用,未引用的资源不写入文档
// 入参: draw 绘制参数,ID由编辑器分配
// 返回: string 资源标识, error 错误信息
@@ -159,21 +183,6 @@ func (e *Editor) resolveEditorStyleDefaults(object GraphicObject, base *DrawPara
return cloneEditorObject(object)
}
-// ObjectStyle 对象外观,nil字段保持原值,颜色的Alpha为nil时保留原颜色透明度
-// StyleObjects使用对象坐标毫米,StyleCompositeObjects的LineWidth使用页面毫米
-type ObjectStyle struct {
- Alpha *int
- Fill *bool
- Stroke *bool
- FillColor *FillColor
- StrokeColor *StrokeColor
- LineWidth *float64
- DashPattern *string
- DashOffset *float64
- Cap *string
- Join *string
-}
-
// StyleObjects 原子更新对象透明度、文字颜色及路径填充和描边样式
// 入参: page 页面索引, ids 对象标识, style 待修改属性
// 返回: error 错误信息
@@ -444,6 +453,56 @@ func editorStrokeScale(ctm string) float64 {
return 1
}
+// StyleImageBorders 原子修改图片边框,保留未修改的颜色、线型及原文扩展
+// 入参: page 页面索引, ids 图片标识, style 边框增量
+// 返回: error 错误信息
+func (e *Editor) StyleImageBorders(page int, ids []string, style ImageBorderStyle) error {
+ for _, value := range []*float64{style.LineWidth, style.HorizontalRadius, style.VerticalRadius} {
+ if value != nil && (!finite(*value) || *value < 0) {
+ return fmt.Errorf("border dimensions must be finite and nonnegative")
+ }
+ }
+ if style.Color != nil {
+ if err := e.editorColor((*FillColor)(style.Color)); err != nil {
+ return err
+ }
+ }
+ objects, _, err := e.selectedObjects(page, ids)
+ if err != nil {
+ return err
+ }
+ for i := range objects {
+ if objects[i].Type != "ImageObject" {
+ return fmt.Errorf("object %q is not an image", ids[i])
+ }
+ if style.Enabled != nil && !*style.Enabled {
+ objects[i].ImageObject.Border = nil
+ continue
+ }
+ border := cloneEditorData(objects[i].ImageObject.Border)
+ if border == nil {
+ if style == (ImageBorderStyle{}) {
+ continue
+ }
+ border = &ImageBorder{}
+ }
+ if style.LineWidth != nil {
+ border.LineWidth = cloneEditorData(style.LineWidth)
+ }
+ if style.HorizontalRadius != nil {
+ border.HorizonalCornerRadius = *style.HorizontalRadius
+ }
+ if style.VerticalRadius != nil {
+ border.VerticalCornerRadius = *style.VerticalRadius
+ }
+ if style.Color != nil {
+ border.BorderColor = (*StrokeColor)(editorStyleColor((*FillColor)(style.Color), (*FillColor)(border.BorderColor)))
+ }
+ objects[i].ImageObject.Border = border
+ }
+ return e.updateObjects(page, objects, true)
+}
+
// SetImageBorders 原子替换图片边框,nil移除边框
// 入参: page 页面索引, ids 图片标识, border 边框样式
// 返回: error 错误信息
diff --git a/ofdgo_writer.go b/ofdgo_writer.go
index 4a8280f..d4c2b51 100644
--- a/ofdgo_writer.go
+++ b/ofdgo_writer.go
@@ -22,9 +22,11 @@ import (
"fmt"
"io"
"maps"
+ "path"
"reflect"
"slices"
"strconv"
+ "strings"
"time"
)
@@ -214,6 +216,19 @@ func (e *Editor) validate() error {
// 入参: write 条目写入方法, progress 保存进度回调,预览时为nil
// 返回: error 错误信息
func (e *Editor) writeParts(write func(string, []byte, bool) error, progress editorProgress) error {
+ if len(e.removedPages) != 0 {
+ output := write
+ write = func(name string, data []byte, binary bool) error {
+ if !binary && strings.EqualFold(path.Ext(name), ".xml") {
+ var err error
+ data, err = pruneCreatedPageReferences(data, e.removedPages)
+ if err != nil {
+ return err
+ }
+ }
+ return output(name, data, binary)
+ }
+ }
fonts, images, spaces, definitions := e.usedResources()
writeXML := func(name string, encode func(*ofdXML)) error {
data, err := encodeOFDXML(encode)