feat(编辑能力): 完善外观与链接编辑,修复文字缩放
build.yaml / build (push) Successful in 40s

This commit is contained in:
xiaoqidun committed 2026-09-24 12:50:51 +08:00
1 parent d7cd3d03b5
commit 06b5df9173
13 files changed
+436 -130

No files matched your search

+13
View File
@@ -184,6 +184,18 @@
<div class="form-row"><span>组合</span><div class="form-format"><button id="groupObjectsButton" class="button" type="button">组合</button><button id="ungroupObjectButton" class="button" type="button">解组</button></div></div>
<div class="form-row"><span>格式</span><div class="form-format"><button id="copyStyleButton" class="button" type="button" aria-label="复制格式">复制</button><button id="pasteStyleButton" class="button" type="button" aria-label="粘贴格式">粘贴</button></div></div>
<div class="form-row"><label for="objectOpacity">透明</label><div class="form-unit"><input id="objectOpacity" type="number" min="0" max="100" step="any" aria-label="透明度" autofocus><span>%</span></div></div>
<details id="objectBorderFields" hidden>
<summary>边框</summary>
<div class="form-row"><label for="borderEnabled">显示</label><input id="borderEnabled" type="checkbox"></div>
<div class="form-pair">
<div class="form-row"><label for="borderWidth">线宽</label><div class="form-unit"><input id="borderWidth" type="number" min="0" step="any"><span>mm</span></div></div>
<div class="form-row"><label for="borderColor">颜色</label><input id="borderColor" class="color-input" type="color"><span id="borderColorOriginal" hidden>原样</span></div>
</div>
<div class="form-pair">
<div class="form-row"><label for="borderHorizontal">横径</label><div class="form-unit"><input id="borderHorizontal" type="number" min="0" step="any" aria-label="水平圆角半径"><span>mm</span></div></div>
<div class="form-row"><label for="borderVertical">纵径</label><div class="form-unit"><input id="borderVertical" type="number" min="0" step="any" aria-label="垂直圆角半径"><span>mm</span></div></div>
</div>
</details>
<details id="objectGradientFields" hidden>
<summary>渐变</summary>
<div class="form-pair">
@@ -199,6 +211,7 @@
</details>
<details id="objectPatternFields" hidden>
<summary>图案</summary>
<div class="form-row"><label for="patternTarget">作用</label><select id="patternTarget"><option value="fill">填充</option><option value="stroke">描边</option></select></div>
<div class="form-pair">
<div class="form-row"><label for="patternWidth">宽度</label><div class="form-unit"><input id="patternWidth" type="number" min="0" step="any"><span>mm</span></div></div>
<div class="form-row"><label for="patternHeight">高度</label><div class="form-unit"><input id="patternHeight" type="number" min="0" step="any"><span>mm</span></div></div>
+139 -27
View File
@@ -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") {
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 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 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);
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;
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);
}
if (Object.keys(pattern).length) style.patternStyle = JSON.stringify(pattern);
if (!el.objectPatternFields.hidden) {
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 (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");
+2 -1
View File
@@ -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, {
+67 -10
View File
@@ -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,11 +2938,21 @@ 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() {
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 {
@@ -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
+11 -73
View File
@@ -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,7 +273,6 @@ 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
@@ -284,78 +285,6 @@ func (e *Editor) CopyPages(indexes []int) ([]int, error) {
result[i] = at + i
}
return result, nil
}
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
}
}
}
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))
}
}
}
return result, nil
}
// DeletePage 删除页面,输出时清理相关目录、跳转、注释和签章位置,保留其他页面及原始签名凭据
@@ -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 {
+16
View File
@@ -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 错误信息
+1
View File
@@ -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
+6 -2
View File
@@ -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)
+56
View File
@@ -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 错误信息
+1 -1
View File
@@ -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
}
+35 -1
View File
@@ -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 路径不存在
+74 -15
View File
@@ -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 错误信息
+15
View File
@@ -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)