diff --git a/assets/webui/index.html b/assets/webui/index.html
index 848ef63..a5ed3f2 100644
--- a/assets/webui/index.html
+++ b/assets/webui/index.html
@@ -184,6 +184,31 @@
@@ -267,6 +292,7 @@
+
扩展
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css
index b53c347..fb6184c 100644
--- a/assets/webui/ofdgo.css
+++ b/assets/webui/ofdgo.css
@@ -774,6 +774,8 @@ button:not(:disabled):hover {
outline-offset: 2px;
}
+.info-custom-row input,
+.gradient-stop-row input,
.form-row input[type="text"],
.form-row input[type="password"],
.form-row input[type="number"],
@@ -798,6 +800,56 @@ button:not(:disabled):hover {
resize: vertical;
}
+.form-dialog details {
+ margin-bottom: 12px;
+}
+
+.form-dialog details > summary {
+ cursor: pointer;
+}
+
+.form-dialog details[open] > summary {
+ margin-bottom: 12px;
+}
+
+#infoCustomRows {
+ max-height: 240px;
+ overflow-y: auto;
+}
+
+.info-custom-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) var(--control-height);
+ gap: 6px;
+ margin: 6px 0;
+}
+
+#infoCustomRows > :first-child {
+ margin-top: 0;
+}
+
+.gradient-stop-row {
+ display: grid;
+ grid-template-columns: 44px minmax(0, 1fr) minmax(0, 1fr) var(--control-height);
+ gap: 6px;
+ margin: 6px 0;
+ align-items: center;
+}
+
+.gradient-stop-head {
+ font-size: 12px;
+}
+
+#gradientStops {
+ max-height: 180px;
+ overflow-y: auto;
+}
+
+#infoCustomRows :focus-visible,
+#gradientStops :focus-visible {
+ outline-offset: -2px;
+}
+
.color-input {
width: calc(2em + 22px);
height: var(--control-height);
diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js
index 95aa894..d2de8bf 100644
--- a/assets/webui/ofdgo.js
+++ b/assets/webui/ofdgo.js
@@ -213,6 +213,8 @@ const el = {
infoPanel: document.querySelector("#infoPanel"),
infoForm: document.querySelector("#infoForm"),
infoTitle: document.querySelector("#infoTitle"),
+ infoCustomRows: document.querySelector("#infoCustomRows"),
+ infoCustomAdd: document.querySelector("#infoCustomAdd"),
infoAuthor: document.querySelector("#infoAuthor"),
infoSubject: document.querySelector("#infoSubject"),
infoCancel: document.querySelector("#infoCancel"),
@@ -286,6 +288,19 @@ const el = {
groupObjectsButton: document.querySelector("#groupObjectsButton"),
ungroupObjectButton: document.querySelector("#ungroupObjectButton"),
objectOpacity: document.querySelector("#objectOpacity"),
+ objectGradientFields: document.querySelector("#objectGradientFields"),
+ gradientTarget: document.querySelector("#gradientTarget"),
+ gradientKind: document.querySelector("#gradientKind"),
+ gradientControls: document.querySelector("#gradientControls"),
+ gradientAngle: document.querySelector("#gradientAngle"),
+ gradientStops: document.querySelector("#gradientStops"),
+ gradientAdd: document.querySelector("#gradientAdd"),
+ objectPatternFields: document.querySelector("#objectPatternFields"),
+ patternWidth: document.querySelector("#patternWidth"),
+ patternHeight: document.querySelector("#patternHeight"),
+ patternXStep: document.querySelector("#patternXStep"),
+ patternYStep: document.querySelector("#patternYStep"),
+ patternCTM: document.querySelector("#patternCTM"),
objectStrokeFields: document.querySelector("#objectStrokeFields"),
objectDash: document.querySelector("#objectDash"),
objectDashRow: document.querySelector("#objectDashRow"),
@@ -590,14 +605,41 @@ editorClick(el.paragraphButton, () => {
el.paragraphCancel.addEventListener("click", () => el.paragraphPanel.close());
editorClick(el.infoButton, () => {
for (const key of ["Title", "Author", "Subject"]) el[`info${key}`].value = state.doc[key.toLowerCase()] || "";
+ el.infoCustomRows.replaceChildren();
+ for (const [index, field] of (state.doc.customData || []).entries()) addCustomDataRow(field, index);
el.infoStatus.textContent = "";
el.infoPanel.showModal();
});
el.infoCancel.addEventListener("click", () => el.infoPanel.close());
+el.infoCustomAdd.addEventListener("click", () => addCustomDataRow());
el.infoForm.addEventListener("submit", async event => {
event.preventDefault();
- if (await changeDocument("ofdgoUpdateInfo", null, el.infoTitle.value, el.infoAuthor.value, el.infoSubject.value)) el.infoPanel.close();
+ const fields = [...el.infoCustomRows.children].map(row => ({Name: row.children[0].value, Value: row.children[1].value, Source: row.sourceIndex}));
+ if (await changeDocument("ofdgoUpdateInfo", null, el.infoTitle.value, el.infoAuthor.value, el.infoSubject.value, JSON.stringify(fields))) el.infoPanel.close();
});
+
+function addCustomDataRow(field = {Name: "", Value: ""}, sourceIndex) {
+ const row = document.createElement("div");
+ row.sourceIndex = sourceIndex;
+ row.className = "info-custom-row";
+ for (const [key, label] of [["Name", "名称"], ["Value", "内容"]]) {
+ const input = document.createElement("input");
+ input.type = "text";
+ input.value = field[key];
+ input.setAttribute("aria-label", label);
+ input.placeholder = label;
+ row.append(input);
+ }
+ const remove = document.createElement("button");
+ remove.type = "button";
+ remove.className = "small-button";
+ remove.textContent = "×";
+ remove.title = "删除字段";
+ remove.setAttribute("aria-label", "删除字段");
+ remove.addEventListener("click", () => row.remove());
+ row.append(remove);
+ el.infoCustomRows.append(row);
+}
el.annotationClose.addEventListener("click", () => el.annotationNote.close());
el.annotationNote.addEventListener("close", () => { state.annotationEdit = null; });
el.penTool.addEventListener("change", () => {
@@ -947,12 +989,22 @@ el.objectBoundsForm.addEventListener("submit", async event => {
if (saved) el.objectBoundsPanel.close();
});
el.objectStyleCancel.addEventListener("click", () => el.objectStylePanel.close());
+el.gradientKind.addEventListener("change", () => {
+ el.gradientControls.hidden = el.gradientKind.value === "keep";
+ el.gradientAngle.disabled = el.gradientKind.value !== "linear";
+ for (const input of el.gradientStops.querySelectorAll("input")) input.disabled = el.gradientKind.value === "keep";
+});
+el.gradientAdd.addEventListener("click", () => addGradientStop(50, "#808080", 0));
el.objectDash.addEventListener("change", () => {
el.objectDashRow.hidden = el.objectDash.value !== "custom";
el.objectDashPattern.required = !el.objectDashRow.hidden;
});
el.objectStyleForm.addEventListener("submit", async event => {
event.preventDefault();
+ if (!el.objectGradientFields.hidden && el.gradientKind.value !== "keep" && el.gradientStops.children.length < 2) {
+ el.objectStyleStatus.textContent = "至少保留两个色标";
+ return;
+ }
const style = readObjectStyle();
const item = canvasEditor.selected;
if (await changeDocument("ofdgoStyleObjects", { ...item, id: canvasEditor.items().map(member => member.id) }, style)) el.objectStylePanel.close();
@@ -1390,6 +1442,21 @@ 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.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;
+ }
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 ? "混合" : "";
@@ -1401,13 +1468,32 @@ 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"].map(key => [key, el[key].value]));
+ state.styleOriginal = Object.fromEntries(["objectOpacity", "objectDash", "objectDashPattern", "objectDashOffset", "objectCap", "objectJoin", "patternWidth", "patternHeight", "patternXStep", "patternYStep", "patternCTM"].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.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 (changed("objectOpacity") && el.objectOpacity.value !== "") style.alpha = Math.round((1 - Number(el.objectOpacity.value) / 100) * 255);
if (!el.objectStrokeFields.hidden) {
if (changed("objectDash") || changed("objectDashPattern")) {
@@ -1421,6 +1507,30 @@ function readObjectStyle() {
return style;
}
+function addGradientStop(position, color, transparency) {
+ const row = document.createElement("div");
+ row.className = "gradient-stop-row";
+ for (const [type, value, label] of [["color", color, "色标颜色"], ["number", position, "色标位置"], ["number", transparency, "色标透明度"]]) {
+ const input = document.createElement("input");
+ input.type = type;
+ input.value = value;
+ input.disabled = el.gradientKind.value === "keep";
+ input.setAttribute("aria-label", label);
+ input.title = label;
+ if (type === "number") { input.min = "0"; input.max = "100"; input.step = "any"; input.required = true; }
+ row.append(input);
+ }
+ const remove = document.createElement("button");
+ remove.type = "button";
+ remove.className = "small-button";
+ remove.textContent = "×";
+ remove.title = "删除色标";
+ remove.setAttribute("aria-label", "删除色标");
+ remove.addEventListener("click", () => row.remove());
+ row.append(remove);
+ el.gradientStops.append(row);
+}
+
function openOutlinePanel(action) {
state.outlineAction = action;
let outline = null, items = state.doc.outlines || [];
@@ -2439,7 +2549,7 @@ async function changeDocument(name, item, ...args) {
}
setEditorInfo(doc);
if (name === "ofdgoUpdateInfo") {
- Object.assign(state.doc, { title: doc.title, author: doc.author, subject: doc.subject });
+ Object.assign(state.doc, { title: doc.title, author: doc.author, subject: doc.subject, customData: doc.customData });
renderMeta();
updateControls();
rememberEditorView(before, revision);
diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go
index 50852a0..616905c 100644
--- a/internal/webui/wasm.go
+++ b/internal/webui/wasm.go
@@ -1712,8 +1712,10 @@ 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
switch object.Type {
case "TextObject":
+ fill = object.TextObject.FillColor
alpha = object.TextObject.Alpha
if paint {
item["color"] = editorColorHex(object.TextObject.FillColor)
@@ -1723,6 +1725,10 @@ func editorAppearance(item map[string]any, object ofdgo.GraphicObject, paint boo
item["imageBorder"] = object.ImageObject.Border != nil
case "PathObject":
path := object.PathObject
+ fill = path.FillColor
+ if box, err := ofdgo.ParseBox(path.Boundary); err == nil {
+ item["paintSize"] = []any{box.W, box.H}
+ }
alpha = path.Alpha
item["dashPattern"], item["cap"], item["join"], item["dashOffset"] = path.DashPattern, path.Cap, path.Join, 0.0
if path.DashOffset != nil {
@@ -1740,6 +1746,10 @@ 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}
+ }
}
// compositePath 解析画布复合对象路径
@@ -2889,20 +2899,85 @@ func changeOutline(args []js.Value) (any, error) {
return editorOutlineInfo{info.(editorInfo), path}, nil
}
-// styleObjects 原子更新对象透明度和路径描边属性
+// styleObjects 原子更新对象外观和图案布局
// 入参: args 页面索引、对象标识数组及样式,省略字段保持原值
// 返回: 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 {
+ return nil, err
+ }
+ }
+ if field := args[2].Get("fillPaint"); !field.IsUndefined() {
+ if err := json.Unmarshal([]byte(field.String()), &style.FillColor); err != nil {
+ return nil, err
+ }
+ }
+ if field := args[2].Get("strokePaint"); !field.IsUndefined() {
+ if err := json.Unmarshal([]byte(field.String()), &style.StrokeColor); err != nil {
+ return nil, err
+ }
+ }
return changeObjects(func() error {
- return currentEditor.StyleObjects(args[0].Int(), stringsFromJS(args[1]), objectStyle(args[2]))
+ return currentEditor.Transaction(func(editor *ofdgo.Editor) error {
+ page, ids := args[0].Int(), stringsFromJS(args[1])
+ for _, paint := range []*ofdgo.FillColor{style.FillColor, (*ofdgo.FillColor)(style.StrokeColor)} {
+ if err := resolveStylePaint(paint); err != nil {
+ return err
+ }
+ }
+ if err := editor.StyleObjects(page, ids, style); err != nil {
+ return err
+ }
+ if pattern != nil {
+ return editor.StylePatterns(page, ids, false, *pattern)
+ }
+ return nil
+ })
})
}
+// resolveStylePaint 将渐变色标中的浏览器色值转换为文档颜色空间
+// 入参: paint 颜色数据
+// 返回: error 错误信息
+func resolveStylePaint(paint *ofdgo.FillColor) error {
+ if paint == nil {
+ return nil
+ }
+ var segments []ofdgo.ShdSegment
+ if paint.AxialShd != nil {
+ segments = paint.AxialShd.Segment
+ } else if paint.RadialShd != nil {
+ segments = paint.RadialShd.Segment
+ }
+ for i := range segments {
+ stop := &segments[i].Color
+ if strings.HasPrefix(stop.Value, "#") {
+ color := ofdgo.FillColor{Alpha: stop.Alpha}
+ if err := setEditorColor(&color, stop.Value); err != nil {
+ return err
+ }
+ *stop = ofdgo.ShdColor{Value: color.Value, ColorSpace: color.ColorSpace, Alpha: color.Alpha}
+ }
+ }
+ return nil
+}
+
// objectStyle 读取高级面板提交的外观字段,省略字段保持原值
// 入参: value 前端样式
// 返回: ofdgo.ObjectStyle 库层样式
func objectStyle(value js.Value) ofdgo.ObjectStyle {
style := ofdgo.ObjectStyle{}
+ if field := value.Get("fill"); !field.IsUndefined() {
+ v := field.Bool()
+ style.Fill = &v
+ }
+ if field := value.Get("stroke"); !field.IsUndefined() {
+ v := field.Bool()
+ style.Stroke = &v
+ }
if field := value.Get("alpha"); !field.IsUndefined() {
v := field.Int()
style.Alpha = &v
@@ -3026,13 +3101,39 @@ func createDocument(args []js.Value) (any, error) {
return previewEditor(editor, args[3].Bool())
}
-// updateInfo 修改标题、作者和主题,保留其他元数据及创建程序标识
-// 入参: args 标题、作者、主题
+// updateInfo 修改文档信息和自定义字段,保留其他元数据及创建程序标识
+// 入参: args 标题、作者、主题、可选自定义字段JSON
// 返回: any 编辑状态, error 错误信息
func updateInfo(args []js.Value) (any, error) {
return changeObjects(func() error {
info := currentEditor.Info
info.Title, info.Author, info.Subject = args[0].String(), args[1].String(), args[2].String()
+ if len(args) > 3 {
+ var submitted []struct {
+ ofdgo.CustomData
+ Source *int
+ }
+ if err := json.Unmarshal([]byte(args[3].String()), &submitted); err != nil {
+ return err
+ }
+ var previous []ofdgo.CustomData
+ if info.CustomDatas != nil {
+ previous = info.CustomDatas.CustomData
+ }
+ fields := make([]ofdgo.CustomData, len(submitted))
+ for i, field := range submitted {
+ if field.Source != nil {
+ if *field.Source < 0 || *field.Source >= len(previous) {
+ return fmt.Errorf("invalid custom data source")
+ }
+ fields[i] = previous[*field.Source]
+ }
+ fields[i].Name, fields[i].Value = field.Name, field.Value
+ }
+ if !slices.Equal(previous, fields) {
+ info.CustomDatas = &ofdgo.CustomDatas{CustomData: fields}
+ }
+ }
currentEditor.SetInfo(info)
return nil
})
diff --git a/internal/webui/webui.go b/internal/webui/webui.go
index 390b0c4..99fd111 100644
--- a/internal/webui/webui.go
+++ b/internal/webui/webui.go
@@ -67,25 +67,26 @@ type pageBoxInfo struct {
// DocumentInfo 文档信息
type DocumentInfo struct {
- Encryption EncryptionInfo `json:"encryption"`
- Version string `json:"version"`
- DocType string `json:"docType"`
- Title string `json:"title"`
- Author string `json:"author"`
- Subject string `json:"subject"`
- CreationDate string `json:"creationDate"`
- ModDate string `json:"modDate"`
- PageCount int `json:"pageCount"`
- FontCount int `json:"fontCount"`
- SignatureCount int `json:"signatureCount"`
- SignatureError string `json:"signatureError,omitempty"`
- AttachmentError string `json:"attachmentError,omitempty"`
- Attachments []AttachmentInfo `json:"attachments,omitempty"`
- Fonts []FontInfo `json:"fonts"`
- Signatures []SignatureInfo `json:"signatures"`
- Pages []PageInfo `json:"pages"`
- Outlines []OutlineInfo `json:"outlines,omitempty"`
- DetailsPending bool `json:"detailsPending,omitempty"`
+ Encryption EncryptionInfo `json:"encryption"`
+ Version string `json:"version"`
+ DocType string `json:"docType"`
+ Title string `json:"title"`
+ Author string `json:"author"`
+ Subject string `json:"subject"`
+ CustomData []ofdgo.CustomData `json:"customData,omitempty"`
+ CreationDate string `json:"creationDate"`
+ ModDate string `json:"modDate"`
+ PageCount int `json:"pageCount"`
+ FontCount int `json:"fontCount"`
+ SignatureCount int `json:"signatureCount"`
+ SignatureError string `json:"signatureError,omitempty"`
+ AttachmentError string `json:"attachmentError,omitempty"`
+ Attachments []AttachmentInfo `json:"attachments,omitempty"`
+ Fonts []FontInfo `json:"fonts"`
+ Signatures []SignatureInfo `json:"signatures"`
+ Pages []PageInfo `json:"pages"`
+ Outlines []OutlineInfo `json:"outlines,omitempty"`
+ DetailsPending bool `json:"detailsPending,omitempty"`
}
// OutlineInfo 目录节点信息
@@ -450,6 +451,9 @@ func (s *Session) Summary() DocumentInfo {
info.Title = docInfo.Title
info.Author = docInfo.Author
info.Subject = docInfo.Subject
+ if docInfo.CustomDatas != nil {
+ info.CustomData = append([]ofdgo.CustomData(nil), docInfo.CustomDatas.CustomData...)
+ }
info.CreationDate = docInfo.CreationDate
info.ModDate = docInfo.ModDate
}
@@ -523,6 +527,9 @@ func (s *Session) Info() DocumentInfo {
info.Title = docInfo.Title
info.Author = docInfo.Author
info.Subject = docInfo.Subject
+ if docInfo.CustomDatas != nil {
+ info.CustomData = append([]ofdgo.CustomData(nil), docInfo.CustomDatas.CustomData...)
+ }
info.CreationDate = docInfo.CreationDate
info.ModDate = docInfo.ModDate
}
diff --git a/ofdgo_editor.go b/ofdgo_editor.go
index 1cb94b9..dc14ad1 100644
--- a/ofdgo_editor.go
+++ b/ofdgo_editor.go
@@ -783,7 +783,9 @@ func (e *Editor) prepareObject(id string, object GraphicObject) (GraphicObject,
}
}
if obj.Border != nil {
- return GraphicObject{}, fmt.Errorf("image borders are not supported for creation")
+ if err := e.validateImageBorder(obj.Border); err != nil {
+ return GraphicObject{}, err
+ }
}
obj.ID = id
boundary, ctm = obj.Boundary, obj.CTM
@@ -802,8 +804,13 @@ func (e *Editor) prepareObject(id string, object GraphicObject) (GraphicObject,
} else if object.Type == "ImageObject" {
object.ImageObject.CTM = fmt.Sprintf("%s 0 0 %s 0 0", ofdNumber(box.W), ofdNumber(box.H))
}
- if drawParam != "" || len(actions) != 0 {
- return GraphicObject{}, fmt.Errorf("draw parameter references and actions are not supported for creation")
+ if drawParam != "" {
+ if _, err := e.editorDrawParam(drawParam, make(map[string]bool)); err != nil {
+ return GraphicObject{}, err
+ }
+ }
+ if err := validateObjectActions(actions); err != nil {
+ return GraphicObject{}, err
}
if err := e.validateObjectClips(clips); err != nil {
return GraphicObject{}, err
diff --git a/ofdgo_editor_color.go b/ofdgo_editor_color.go
index 7573122..3397390 100644
--- a/ofdgo_editor_color.go
+++ b/ofdgo_editor_color.go
@@ -23,6 +23,70 @@ import (
"strings"
)
+// PatternStyle 图案尺寸、步进和变换增量,nil字段保持原值,单位为毫米
+type PatternStyle struct {
+ Width *float64
+ Height *float64
+ XStep *float64
+ YStep *float64
+ CTM *string
+}
+
+// StylePatterns 修改已有图案布局,保留单元内容、资源引用和未知扩展
+// 入参: page 页面索引, ids 对象标识, stroke 是否修改描边, style 图案布局增量
+// 返回: error 错误信息
+func (e *Editor) StylePatterns(page int, ids []string, stroke bool, style PatternStyle) error {
+ for _, size := range []*float64{style.Width, style.Height} {
+ if size != nil && (!finite(*size) || *size <= 0) {
+ return fmt.Errorf("pattern dimensions must be positive and finite")
+ }
+ }
+ for _, step := range []*float64{style.XStep, style.YStep} {
+ if step != nil && (!finite(*step) || *step < 0) {
+ return fmt.Errorf("pattern steps must be nonnegative and finite")
+ }
+ }
+ if style.CTM != nil && *style.CTM != "" {
+ if _, err := creationNumbers(*style.CTM, 6); err != nil {
+ return err
+ }
+ }
+ objects, _, err := e.selectedObjects(page, ids)
+ if err != nil {
+ return err
+ }
+ for i := range objects {
+ objects[i] = cloneEditorData(objects[i])
+ object := &objects[i]
+ var fill *FillColor
+ switch object.Type {
+ case "TextObject":
+ fill = object.TextObject.FillColor
+ if stroke {
+ fill = (*FillColor)(object.TextObject.StrokeColor)
+ }
+ case "PathObject", "Path":
+ fill = object.PathObject.FillColor
+ if stroke {
+ fill = (*FillColor)(object.PathObject.StrokeColor)
+ }
+ }
+ if fill == nil || fill.Pattern == nil {
+ return fmt.Errorf("object %q does not use a pattern", ids[i])
+ }
+ pattern := fill.Pattern
+ for _, pair := range []struct{ value, target *float64 }{{style.Width, &pattern.Width}, {style.Height, &pattern.Height}, {style.XStep, &pattern.XStep}, {style.YStep, &pattern.YStep}} {
+ if pair.value != nil {
+ *pair.target = *pair.value
+ }
+ }
+ if style.CTM != nil {
+ pattern.CTM = *style.CTM
+ }
+ }
+ return e.updateObjects(page, objects, true)
+}
+
// Color 解析当前文档的纯色,支持RGB、灰度、CMYK及调色板,不修改原颜色
// 入参: value 颜色,nil表示默认黑色
// 返回: color.NRGBA 非预乘颜色, error 错误信息
@@ -141,9 +205,42 @@ func (e *Editor) RGBColor(value color.NRGBA) (*FillColor, error) {
return result, nil
}
+// PaintObjects 更新文字或路径的填充或描边,保留定位、其他样式和原文扩展
+// 入参: page 页面索引, ids 对象标识, paint 绘制颜色,nil恢复默认, stroke 是否修改描边
+// 返回: error 错误信息
+func (e *Editor) PaintObjects(page int, ids []string, paint *FillColor, stroke bool) error {
+ if err := e.editorColor(paint); err != nil {
+ return err
+ }
+ objects, _, err := e.selectedObjects(page, ids)
+ if err != nil {
+ return err
+ }
+ for i := range objects {
+ object := &objects[i]
+ switch object.Type {
+ case "TextObject":
+ if stroke {
+ object.TextObject.StrokeColor = (*StrokeColor)(cloneEditorData(paint))
+ } else {
+ object.TextObject.FillColor = cloneEditorData(paint)
+ }
+ case "PathObject":
+ if stroke {
+ object.PathObject.StrokeColor = (*StrokeColor)(cloneEditorData(paint))
+ } else {
+ object.PathObject.FillColor = cloneEditorData(paint)
+ }
+ default:
+ return fmt.Errorf("object %q does not have text or path paint", editorObjectID(*object))
+ }
+ }
+ return e.updateObjects(page, objects, true)
+}
+
// editorPaintable 判断文字与路径颜色能否在保留其他字段的前提下修改
// 入参: object 图形对象
-// 返回: bool 是否支持纯色编辑
+// 返回: bool 是否支持绘制颜色编辑
func (e *Editor) editorPaintable(object GraphicObject) bool {
var fill, stroke *FillColor
switch object.Type {
@@ -155,17 +252,111 @@ func (e *Editor) editorPaintable(object GraphicObject) bool {
return false
}
for _, value := range []*FillColor{fill, stroke} {
- if _, err := e.Color(value); err != nil {
+ if err := e.editorColor(value); err != nil {
return false
}
}
return true
}
-// editorColor 校验当前文档的颜色空间和分量
+// collectPaintReferences 收集纯色、渐变和图案引用的资源
+// 入参: paint 绘制颜色, used 引用集合
+func collectPaintReferences(paint *FillColor, used map[string]bool) {
+ if paint == nil {
+ return
+ }
+ used[paint.ColorSpace] = true
+ var segments []ShdSegment
+ if paint.AxialShd != nil {
+ segments = paint.AxialShd.Segment
+ }
+ if paint.RadialShd != nil {
+ segments = paint.RadialShd.Segment
+ }
+ for _, segment := range segments {
+ used[segment.Color.ColorSpace] = true
+ }
+ if paint.Pattern != nil {
+ for _, object := range paint.Pattern.CellContent.Objects {
+ collectObjectReferences(object, used)
+ }
+ }
+}
+
+// collectObjectReferences 收集对象和嵌套绘制内容引用的资源
+// 入参: object 图形对象, used 引用集合
+func collectObjectReferences(object GraphicObject, used map[string]bool) {
+ switch object.Type {
+ case "TextObject":
+ used[object.TextObject.Font], used[object.TextObject.DrawParam] = true, true
+ collectPaintReferences(object.TextObject.FillColor, used)
+ collectPaintReferences((*FillColor)(object.TextObject.StrokeColor), used)
+ case "PathObject", "Path":
+ used[object.PathObject.DrawParam] = true
+ collectPaintReferences(object.PathObject.FillColor, used)
+ collectPaintReferences((*FillColor)(object.PathObject.StrokeColor), used)
+ case "ImageObject":
+ used[object.ImageObject.ResourceID], used[object.ImageObject.ImageMask] = true, true
+ if object.ImageObject.Border != nil {
+ collectPaintReferences((*FillColor)(object.ImageObject.Border.BorderColor), used)
+ }
+ case "CompositeObject", "CompositeGraphicUnit":
+ collectCompositeReferences(object.CompositeGraphicUnit, used)
+ }
+}
+
+// editorColor 校验当前文档的纯色和渐变
// 入参: value 颜色
// 返回: error 错误信息
func (e *Editor) editorColor(value *FillColor) error {
- _, err := e.Color(value)
- return err
+ if value == nil || value.AxialShd == nil && value.RadialShd == nil {
+ _, err := e.Color(value)
+ return err
+ }
+ if value.unsupported || value.Pattern != nil || value.AxialShd != nil && value.RadialShd != nil || value.Value != "" || value.Index != nil {
+ return fmt.Errorf("specify one paint source")
+ }
+ if value.Alpha != nil && (*value.Alpha < 0 || *value.Alpha > 255) {
+ return fmt.Errorf("color alpha must be between 0 and 255")
+ }
+ var mapType, extend, start, end string
+ var unit float64
+ var segments []ShdSegment
+ if node := value.AxialShd; node != nil {
+ mapType, unit, extend, start, end, segments = node.MapType, node.MapUnit, node.Extend, node.StartPoint, node.EndPoint, node.Segment
+ } else {
+ node := value.RadialShd
+ mapType, unit, extend, start, end, segments = node.MapType, node.MapUnit, node.Extend, node.StartPoint, node.EndPoint, node.Segment
+ if !finite(node.StartRadius) || !finite(node.EndRadius) || node.StartRadius < 0 || node.EndRadius < 0 || !finite(node.Eccentricity) || node.Eccentricity < 0 || node.Eccentricity >= 1 || !finite(node.Angle) {
+ return fmt.Errorf("invalid radial gradient geometry")
+ }
+ }
+ if !slices.Contains([]string{"", "Direct", "Repeat", "Reflect"}, mapType) || !finite(unit) || unit < 0 || (mapType == "Repeat" || mapType == "Reflect") && unit == 0 || !slices.Contains([]string{"", "0", "1", "2", "3"}, extend) {
+ return fmt.Errorf("invalid gradient mapping")
+ }
+ for _, point := range []string{start, end} {
+ if _, err := creationNumbers(point, 2); err != nil {
+ return err
+ }
+ }
+ if len(segments) < 2 {
+ return fmt.Errorf("gradient requires at least two stops")
+ }
+ previous := -1.0
+ positions := gradientPositions(segments)
+ for index, segment := range segments {
+ position := positions[index]
+ if !finite(position) || position < previous || position < 0 || position > 1 {
+ return fmt.Errorf("gradient stops must be ordered within zero and one")
+ }
+ previous = position
+ color := segment.Color
+ if color.ColorSpace == "" {
+ color.ColorSpace = value.ColorSpace
+ }
+ if _, err := e.Color(&FillColor{Value: color.Value, Index: color.Index, ColorSpace: color.ColorSpace, Alpha: color.Alpha}); err != nil {
+ return err
+ }
+ }
+ return nil
}
diff --git a/ofdgo_editor_composite.go b/ofdgo_editor_composite.go
index f8c2d99..20ef2c2 100644
--- a/ofdgo_editor_composite.go
+++ b/ofdgo_editor_composite.go
@@ -1068,25 +1068,6 @@ func collectCompositeReferences(composite CompositeGraphicUnit, used map[string]
used[composite.ResourceID] = true
used[composite.DrawParam] = true
for _, object := range composite.Objects {
- var fill, stroke *FillColor
- switch object.Type {
- case "TextObject":
- used[object.TextObject.Font] = true
- used[object.TextObject.DrawParam] = true
- fill, stroke = object.TextObject.FillColor, (*FillColor)(object.TextObject.StrokeColor)
- case "PathObject":
- used[object.PathObject.DrawParam] = true
- fill, stroke = object.PathObject.FillColor, (*FillColor)(object.PathObject.StrokeColor)
- case "ImageObject":
- used[object.ImageObject.ResourceID] = true
- used[object.ImageObject.ImageMask] = true
- case "CompositeObject", "CompositeGraphicUnit":
- collectCompositeReferences(object.CompositeGraphicUnit, used)
- }
- for _, color := range []*FillColor{fill, stroke} {
- if color != nil {
- used[color.ColorSpace] = true
- }
- }
+ collectObjectReferences(object, used)
}
}
diff --git a/ofdgo_editor_composite_insert.go b/ofdgo_editor_composite_insert.go
index 5f52844..6202b75 100644
--- a/ofdgo_editor_composite_insert.go
+++ b/ofdgo_editor_composite_insert.go
@@ -93,8 +93,10 @@ func (e *Editor) CopyObjectsToComposite(page int, path ObjectPath, objects []Gra
return err
}
if node.object.Type == "PathObject" {
- _, styleErr := e.resolveEditorStyle(node.object, e.copiedLayerStyle(source))
- if styleErr != nil && editReason(styleErr) == EditUnsupportedColor || !e.editorPaintable(node.object) {
+ style, styleErr := e.resolveEditorStyle(node.object, e.copiedLayerStyle(source))
+ _, fillErr := e.Color(style.PathObject.FillColor)
+ _, strokeErr := e.Color((*FillColor)(style.PathObject.StrokeColor))
+ if styleErr != nil && editReason(styleErr) == EditUnsupportedColor || fillErr != nil || strokeErr != nil {
node, err = e.wrapCompositePath(node)
if err != nil {
return err
@@ -235,11 +237,11 @@ func (e *Editor) neutralDrawParam() (string, error) {
return e.addEditorDrawParam(DrawParam{LineWidth: defaultPathLineWidth, Cap: "Butt", Join: "Miter", MiterLimit: defaultMiterLimit, DashOffset: &zero, dashPatternSet: true, FillColor: fill, StrokeColor: (*StrokeColor)(fill)})
}
-// addEditorDrawParam 注册完整有效绘制参数,相同外观复用资源
-// 入参: draw 不含继承关系的有效参数
+// addEditorDrawParam 注册已校验的绘制参数,相同定义复用资源
+// 入参: draw 绘制参数
// 返回: string 资源标识, error 错误信息
func (e *Editor) addEditorDrawParam(draw DrawParam) (string, error) {
- draw.ID, draw.Relative, draw.ResourceID, draw.BaseLoc, draw.Link = "", "", "", "", ""
+ draw.ID, draw.ResourceID, draw.BaseLoc, draw.Link = "", "", "", ""
for _, resource := range e.resources {
if resource.draw != nil {
previous := *resource.draw
@@ -257,10 +259,14 @@ func (e *Editor) addEditorDrawParam(draw DrawParam) (string, error) {
x.root("Res", ofdAttrs{{Name: xml.Name{Local: "BaseLoc"}, Value: "."}})
x.start("DrawParams", nil)
attrs := ofdAttrs{}
- for _, pair := range [][2]string{{"ID", draw.ID}, {"LineWidth", ofdNumber(draw.LineWidth)}, {"Cap", draw.Cap}, {"Join", draw.Join}, {"MiterLimit", ofdNumber(draw.MiterLimit)}, {"Font", draw.Font}} {
+ for _, pair := range [][2]string{{"ID", draw.ID}, {"Relative", draw.Relative}, {"Cap", draw.Cap}, {"Join", draw.Join}, {"Font", draw.Font}} {
attrs.add(pair[0], pair[1])
}
- attrs = append(attrs, xml.Attr{Name: xml.Name{Local: "DashPattern"}, Value: draw.DashPattern})
+ attrs.number("LineWidth", draw.LineWidth)
+ attrs.number("MiterLimit", draw.MiterLimit)
+ if draw.Relative == "" || draw.dashPatternSet || draw.DashPattern != "" {
+ attrs = append(attrs, xml.Attr{Name: xml.Name{Local: "DashPattern"}, Value: draw.DashPattern})
+ }
if draw.DashOffset != nil {
attrs.add("DashOffset", ofdNumber(*draw.DashOffset))
}
diff --git a/ofdgo_editor_link.go b/ofdgo_editor_link.go
index c152888..06fb213 100644
--- a/ofdgo_editor_link.go
+++ b/ofdgo_editor_link.go
@@ -27,6 +27,88 @@ type AnnotationLink struct {
Dest *Dest
}
+// SetObjectActions 原子替换普通对象的链接动作,空列表移除动作
+// 入参: page 页面索引, ids 对象标识, actions 链接动作
+// 返回: error 错误信息
+func (e *Editor) SetObjectActions(page int, ids []string, actions []Action) error {
+ if err := validateObjectActions(actions); err != nil {
+ return err
+ }
+ objects, _, err := e.selectedObjects(page, ids)
+ if err != nil {
+ return err
+ }
+ for i := range objects {
+ switch objects[i].Type {
+ case "TextObject":
+ objects[i].TextObject.Actions = cloneEditorData(actions)
+ case "PathObject", "Path":
+ objects[i].PathObject.Actions = cloneEditorData(actions)
+ case "ImageObject":
+ objects[i].ImageObject.Actions = cloneEditorData(actions)
+ case "CompositeObject", "CompositeGraphicUnit":
+ objects[i].CompositeGraphicUnit.Actions = cloneEditorData(actions)
+ default:
+ return fmt.Errorf("object %q cannot contain actions", ids[i])
+ }
+ }
+ return e.updateObjects(page, objects, true)
+}
+
+// validateObjectActions 校验新建对象的链接动作,目标不必可达
+// 入参: actions 动作列表
+// 返回: error 错误信息
+func validateObjectActions(actions []Action) error {
+ for _, action := range actions {
+ if !slices.Contains([]string{"CLICK", "DO", "PO"}, action.Event) {
+ return fmt.Errorf("unsupported action event %q", action.Event)
+ }
+ if action.GotoA != nil || action.Sound != nil || action.Movie != nil {
+ return fmt.Errorf("new attachment and multimedia actions require resource registration")
+ }
+ if (action.URI == nil) == (action.Goto == nil) {
+ return fmt.Errorf("specify one URI or goto action")
+ }
+ if action.Goto != nil {
+ if (action.Goto.Dest == nil) == (action.Goto.Bookmark == nil) {
+ return fmt.Errorf("specify one destination or bookmark")
+ }
+ if action.Goto.Dest != nil {
+ if _, err := annotationLinkXML(AnnotationLink{Dest: action.Goto.Dest}); err != nil {
+ return err
+ }
+ }
+ }
+ if action.Region != nil {
+ for _, area := range action.Region.Area {
+ if _, err := creationNumbers(area.Start, 2); err != nil {
+ return err
+ }
+ for _, command := range area.Command {
+ var points []string
+ switch command.Type {
+ case "Line":
+ points = []string{command.Point1}
+ case "QuadraticBezier":
+ points = []string{command.Point1, command.Point2}
+ case "CubicBezier":
+ points = []string{command.Point1, command.Point2, command.Point3}
+ case "Close":
+ default:
+ return fmt.Errorf("unsupported new action region command %q", command.Type)
+ }
+ for _, point := range points {
+ if _, err := creationNumbers(point, 2); err != nil {
+ return err
+ }
+ }
+ }
+ }
+ }
+ }
+ return nil
+}
+
// AnnotationLink 读取注解中唯一的点击跳转,书签目标保持原始动作
// 入参: page 页面索引, id 注解标识
// 返回: Action 跳转动作, error 错误信息
@@ -201,9 +283,6 @@ func (e *Editor) UpdateAnnotationLink(page int, id string, target AnnotationLink
return nil, err
}
position := objects[0].open
- if clips := objects[0].child("Clips"); clips != nil {
- position = clips.end
- }
data = editorPatchXML(data, []editorXMLPatch{{position, position, bytes.TrimPrefix(actions, []byte(xml.Header))}})
}
}
diff --git a/ofdgo_editor_metadata.go b/ofdgo_editor_metadata.go
index 9b1cb8e..479615a 100644
--- a/ofdgo_editor_metadata.go
+++ b/ofdgo_editor_metadata.go
@@ -18,6 +18,7 @@ import (
"bytes"
"encoding/xml"
"fmt"
+ "io"
"reflect"
)
@@ -62,7 +63,7 @@ func (e *Editor) sourceInfoXML() ([]byte, error) {
return data, nil
}
-// editorCustomDatas 更新自定义字段,保留容器扩展与同名字段的未知属性和子节点
+// editorCustomDatas 更新自定义字段,保留原字段及容器的扩展内容
// 入参: data 原XML, info 文档信息节点, values 新字段
// 返回: []byte 修改后的XML, error 编码错误
func editorCustomDatas(data []byte, info *editorXML, values *CustomDatas) ([]byte, error) {
@@ -82,9 +83,26 @@ func editorCustomDatas(data []byte, info *editorXML, values *CustomDatas) ([]byt
}
}
var encoded [][]byte
+ used := make(map[*editorXML]bool)
for _, item := range items {
- matches := byName[item.Name]
- if len(matches) == 0 {
+ if item.sourceIndex > 0 && item.sourceIndex <= len(nodes) {
+ used[nodes[item.sourceIndex-1]] = true
+ }
+ }
+ for _, item := range items {
+ var node *editorXML
+ if item.sourceIndex > 0 && item.sourceIndex <= len(nodes) {
+ node = nodes[item.sourceIndex-1]
+ } else {
+ for _, candidate := range byName[item.Name] {
+ if !used[candidate] {
+ node = candidate
+ used[node] = true
+ break
+ }
+ }
+ }
+ if node == nil {
entry, err := xml.Marshal(struct {
XMLName xml.Name
CustomData
@@ -95,26 +113,30 @@ func editorCustomDatas(data []byte, info *editorXML, values *CustomDatas) ([]byt
encoded = append(encoded, entry)
continue
}
- node := matches[0]
- byName[item.Name] = matches[1:]
var original CustomData
if err := xml.Unmarshal(data[node.start:node.end], &original); err != nil {
return nil, err
}
- if original.Value == item.Value {
- encoded = append(encoded, data[node.start:node.end])
+ if original.Name != item.Name {
+ entry, err := editorXMLAttribute(data, node, "Name", item.Name)
+ if err != nil {
+ return nil, err
+ }
+ renamed, err := parseEditorXML(entry)
+ if err != nil {
+ return nil, err
+ }
+ updated, err := editorCustomDataValue(entry, renamed, original.Value, item.Value)
+ if err != nil {
+ return nil, err
+ }
+ encoded = append(encoded, updated)
continue
}
- var content bytes.Buffer
- if err := xml.EscapeText(&content, []byte(item.Value)); err != nil {
+ entry, err := editorCustomDataValue(data, node, original.Value, item.Value)
+ if err != nil {
return nil, err
}
- for _, child := range node.children {
- content.Write(data[child.start:child.end])
- }
- patch := editorXMLContent(data, node, content.Bytes())
- entry := append(bytes.Clone(data[node.start:patch.start]), patch.data...)
- entry = append(entry, data[patch.end:node.end]...)
encoded = append(encoded, entry)
}
if container == nil {
@@ -147,3 +169,44 @@ func editorCustomDatas(data []byte, info *editorXML, values *CustomDatas) ([]byt
}
return editorPatchXML(data, patches), nil
}
+
+// editorCustomDataValue 修改字段文本,保留扩展子节点和注释
+// 入参: data 原XML, node 字段节点, before 原值, after 新值
+// 返回: []byte 字段XML, error 编码错误
+func editorCustomDataValue(data []byte, node *editorXML, before, after string) ([]byte, error) {
+ if before == after {
+ return data[node.start:node.end], nil
+ }
+ var content bytes.Buffer
+ if err := xml.EscapeText(&content, []byte(after)); err != nil {
+ return nil, err
+ }
+ if node.open < node.close {
+ inner := data[node.open:node.close]
+ decoder := xml.NewDecoder(bytes.NewReader(inner))
+ depth := 0
+ for {
+ start := decoder.InputOffset()
+ token, err := decoder.RawToken()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+ _, text := token.(xml.CharData)
+ if !text || depth != 0 {
+ content.Write(inner[start:decoder.InputOffset()])
+ }
+ switch token.(type) {
+ case xml.StartElement:
+ depth++
+ case xml.EndElement:
+ depth--
+ }
+ }
+ }
+ patch := editorXMLContent(data, node, content.Bytes())
+ entry := append(bytes.Clone(data[node.start:patch.start]), patch.data...)
+ return append(entry, data[patch.end:node.end]...), nil
+}
diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go
index 0245c59..b4e6dab 100644
--- a/ofdgo_editor_open.go
+++ b/ofdgo_editor_open.go
@@ -133,6 +133,11 @@ func (r *Reader) Editor() (*Editor, error) {
if err != nil {
return nil, err
}
+ if info.CustomDatas != nil {
+ for i := range info.CustomDatas.CustomData {
+ info.CustomDatas.CustomData[i].sourceIndex = i + 1
+ }
+ }
e := NewEditor()
e.encryption = r.encryption
e.Info, e.maxID = cloneEditorData(*info), maximum
@@ -527,6 +532,7 @@ func (e *Editor) objectCapabilities(object GraphicObject, orderable map[*editorX
all.Copy = editorXMLCopyable(node) && (origin.reason == nil || object.Type == "PathObject" && editReason(origin.reason) == EditUnsupportedColor)
if origin.reason != nil {
all.Update, all.Reflow, all.ReplaceFont, all.Paint = false, false, false, false
+ all.Paint = (object.Type == "TextObject" || object.Type == "PathObject") && editReason(origin.reason) == EditUnsupportedColor
all.Reason, all.ReasonCode = origin.reason.Error(), editReason(origin.reason)
} else if !editorXMLSupported(node) {
all.Update, all.Reflow, all.ReplaceFont = false, false, false
@@ -632,6 +638,14 @@ func editorPreservedObject(before, after GraphicObject) bool {
// 返回: GraphicObject 独立副本, error 错误信息
func (e *Editor) prepareCopiedObject(id string, object GraphicObject) (GraphicObject, error) {
origin := e.snapshotOrigin(object)
+ if origin == nil && object.origin != nil {
+ references := make(map[string]bool)
+ collectObjectReferences(object, references)
+ delete(references, "")
+ if len(references) != 0 {
+ return GraphicObject{}, fmt.Errorf("copying a foreign snapshot requires importing its resources")
+ }
+ }
object.origin = nil
if origin == nil {
return e.prepareObject(id, object)
diff --git a/ofdgo_editor_style.go b/ofdgo_editor_style.go
index 37907a4..756dd88 100644
--- a/ofdgo_editor_style.go
+++ b/ofdgo_editor_style.go
@@ -21,6 +21,37 @@ import (
"strings"
)
+// AddDrawParam 注册绘制参数,保留继承引用,未引用的资源不写入文档
+// 入参: draw 绘制参数,ID由编辑器分配
+// 返回: string 资源标识, error 错误信息
+func (e *Editor) AddDrawParam(draw DrawParam) (string, error) {
+ if draw.ResourceID != "" || draw.BaseLoc != "" || draw.Link != "" {
+ return "", fmt.Errorf("external draw parameter references cannot be registered directly")
+ }
+ base, err := e.editorDrawParam(draw.Relative, make(map[string]bool))
+ if err != nil {
+ return "", err
+ }
+ effective := mergeDrawParam(*base, &draw)
+ if err := validateEditorStroke(PathObject{LineWidth: draw.LineWidth, Cap: draw.Cap, Join: draw.Join, MiterLimit: draw.MiterLimit, DashPattern: draw.DashPattern, DashOffset: draw.DashOffset}); err != nil {
+ return "", err
+ }
+ if !finite(draw.Size) || draw.Size < 0 || draw.Weight < 0 || draw.Weight > 900 || draw.Weight%100 != 0 {
+ return "", fmt.Errorf("invalid draw parameter font size or weight")
+ }
+ if effective.Font != "" {
+ if _, err := e.editorFont(effective.Font); err != nil {
+ return "", err
+ }
+ }
+ for _, paint := range []*FillColor{draw.FillColor, (*FillColor)(draw.StrokeColor)} {
+ if err := e.editorColor(paint); err != nil {
+ return "", err
+ }
+ }
+ return e.addEditorDrawParam(cloneEditorData(draw))
+}
+
// editorDrawParam 解析原文档的绘制参数继承链,缺失或循环引用不进入编辑快照
// 入参: id 绘制参数标识, visited 已访问标识
// 返回: *DrawParam 合并参数, error 错误信息
@@ -121,7 +152,7 @@ func (e *Editor) resolveEditorStyleDefaults(object GraphicObject, base *DrawPara
obj.FillColor, obj.StrokeColor = style.FillColor, style.StrokeColor
}
for _, color := range []*FillColor{style.FillColor, (*FillColor)(style.StrokeColor)} {
- if err := e.editorColor(color); err != nil {
+ if _, err := e.Color(color); err != nil {
return GraphicObject{}, &EditError{Code: EditUnsupportedColor, Err: fmt.Errorf("unsupported draw parameter color: %w", err)}
}
}
@@ -413,6 +444,48 @@ func editorStrokeScale(ctm string) float64 {
return 1
}
+// SetImageBorders 原子替换图片边框,nil移除边框
+// 入参: page 页面索引, ids 图片标识, border 边框样式
+// 返回: error 错误信息
+func (e *Editor) SetImageBorders(page int, ids []string, border *ImageBorder) error {
+ if err := e.validateImageBorder(border); 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])
+ }
+ objects[i].ImageObject.Border = cloneEditorData(border)
+ }
+ return e.updateObjects(page, objects, true)
+}
+
+// validateImageBorder 校验图片边框的线型、圆角和绘制颜色
+// 入参: border 边框
+// 返回: error 错误信息
+func (e *Editor) validateImageBorder(border *ImageBorder) error {
+ if border == nil {
+ return nil
+ }
+ for _, radius := range []float64{border.HorizonalCornerRadius, border.VerticalCornerRadius} {
+ if !finite(radius) || radius < 0 {
+ return fmt.Errorf("image corner radius must be finite and nonnegative")
+ }
+ }
+ stroke := PathObject{DashPattern: border.DashPattern, DashOffset: &border.DashOffset}
+ if border.LineWidth != nil {
+ stroke.LineWidth = *border.LineWidth
+ }
+ if err := validateEditorStroke(stroke); err != nil {
+ return err
+ }
+ return e.editorColor((*FillColor)(border.BorderColor))
+}
+
// validateEditorStroke 校验路径描边尺寸、端点和虚线
// 入参: path 路径对象
// 返回: error 错误信息
diff --git a/ofdgo_editor_text.go b/ofdgo_editor_text.go
index aeabbf7..629ef89 100644
--- a/ofdgo_editor_text.go
+++ b/ofdgo_editor_text.go
@@ -18,6 +18,7 @@ import (
"errors"
"fmt"
"image/color"
+ "strconv"
"strings"
"unicode/utf8"
@@ -112,13 +113,22 @@ func (e *Editor) UpdateTextContent(page int, id, value string, style TextStyle)
if err != nil {
return err
}
- if !capability.ReplaceFont {
+ if !capability.ReplaceFont && (style != (TextStyle{}) || !capability.Transform) {
return capability.editError()
}
object, err := e.Object(page, id)
if err != nil {
return err
}
+ if !capability.LayoutKnown && style == (TextStyle{}) {
+ if object.Type != "TextObject" {
+ return fmt.Errorf("object %q is not text", id)
+ }
+ if err := e.RewriteText(&object.TextObject, value); err != nil {
+ return err
+ }
+ return e.updateObjects(page, []GraphicObject{object}, true)
+ }
updates, err := e.styleTextObjects([]GraphicObject{object}, style)
if err != nil {
return err
@@ -136,20 +146,18 @@ func (e *Editor) UpdateTextContent(page int, id, value string, style TextStyle)
return e.updateObjects(page, updates, true)
}
-// RewriteText 保留普通横向原文的字形定位,不修改对象样式或文档
+// RewriteText 保留原文的字形定位,不修改对象样式或文档
// 等长替换保留各字原位;单段增删保留前缀和后缀步进,新增字符沿用局部字距
-// 不跨定位段增删或自动生成新行,复杂字形映射需单独处理
+// 方向、缩放和修饰原样保留;复杂布局仅支持等长替换,合并字形需显式重排
// 入参: obj 原文字对象, value 新内容
// 返回: error 错误信息
func (e *Editor) RewriteText(obj *TextObject, value string) error {
- if obj.ReadDirection != 0 || obj.CharDirection != 0 || len(obj.CGTransform) != 0 || obj.VScale != 0 || obj.Decoration != "" {
- return &EditError{Code: EditUnsupportedObject, Err: fmt.Errorf("text positioning is not supported for content editing")}
- }
value = strings.ReplaceAll(strings.ReplaceAll(value, "\r\n", "\n"), "\r", "\n")
if !utf8.ValidString(value) || strings.Contains(value, "\t") || strings.Trim(value, "\n") == "" {
return fmt.Errorf("text must contain UTF-8 characters without tabs")
}
check := cloneEditorData(*obj)
+ check.VScale, check.Decoration = 0, ""
if err := e.prepareText(&check); err != nil {
var missing *MissingGlyphError
if !errors.As(err, &missing) {
@@ -160,8 +168,16 @@ func (e *Editor) RewriteText(obj *TextObject, value string) error {
if err != nil {
return err
}
+ old, next := []rune(obj.Text()), []rune(value)
+ complex := obj.ReadDirection != 0 || obj.CharDirection != 0 || len(obj.CGTransform) != 0 || obj.VScale != 0 || obj.Decoration != ""
+ if complex && len(old) != len(next) {
+ return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing positioned text length requires explicit layout")}
+ }
var missing []rune
- for _, char := range value {
+ for i, char := range next {
+ if len(old) == len(next) && old[i] == char {
+ continue
+ }
if char != '\n' && sfnt.GlyphIndex(char) == 0 {
missing = append(missing, char)
}
@@ -169,8 +185,8 @@ func (e *Editor) RewriteText(obj *TextObject, value string) error {
if err := missingGlyphError(obj.Font, missing); err != nil {
return err
}
- old, next := []rune(obj.Text()), []rune(value)
codes := append([]TextCode(nil), obj.TextCode...)
+ transforms := append([]CGTransform(nil), obj.CGTransform...)
if len(old) == len(next) {
offset := 0
for i := range codes {
@@ -240,10 +256,33 @@ func (e *Editor) RewriteText(obj *TextObject, value string) error {
}
check = *obj
check.TextCode = codes
+ position := 0
+ for i, code := range codes {
+ before, after := textCodeRunes(obj.TextCode[i].Value), textCodeRunes(code.Value)
+ if len(before) == len(after) {
+ for j, char := range after {
+ if before[j] == char {
+ continue
+ }
+ for k := range transforms {
+ transform := &transforms[k]
+ if position+j < transform.CodePosition || position+j >= transform.CodePosition+transform.CodeCount {
+ continue
+ }
+ if transform.CodeCount != 1 || transform.GlyphCount != 1 {
+ return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing a combined glyph requires explicit layout")}
+ }
+ transform.Glyphs = strconv.Itoa(int(sfnt.GlyphIndex(char)))
+ }
+ }
+ }
+ position += len(after)
+ }
+ check.CGTransform = transforms
if err := validateEditorGeometry(GraphicObject{Type: "TextObject", TextObject: check}); err != nil {
return err
}
- obj.TextCode, obj.layout = codes, nil
+ obj.TextCode, obj.CGTransform, obj.layout = codes, transforms, nil
return nil
}
diff --git a/ofdgo_font_backend.go b/ofdgo_font_backend.go
index 948863c..7259850 100644
--- a/ofdgo_font_backend.go
+++ b/ofdgo_font_backend.go
@@ -49,11 +49,13 @@ type FontShaper interface {
ShapeText(value string, size float64) ([]ShapedGlyph, error)
}
-// TextShapeOptions 指定单一方向文字的塑形参数,不执行双向段落分析或字体回退
+// TextShapeOptions 指定文字塑形参数,Bidi启用双向及脚本分段,不执行字体回退
// Direction为空或ltr时从左向右,rtl时从右向左;Script为四字母ISO15924代码,空值自动检测
+// Bidi启用时Direction指定段落方向,各文字片段按双向算法排列
// Language为语言标签,Features为逗号分隔的全局OpenType特性,如kern=0,liga=1,不接受字符区间
// 零值保持原有FontShaper行为,非零值要求FontShaperOptions能力
type TextShapeOptions struct {
+ Bidi bool
Direction string
Script string
Language string
@@ -68,9 +70,11 @@ type FontShaperOptions interface {
}
// ShapedGlyph 保存塑形后的字形、原文簇和基线定位
+// Bidi模式下VisualOrder为簇从左到右的零起始顺序,同簇字形共用顺序
type ShapedGlyph struct {
Glyph uint16
Cluster int
+ VisualOrder int
X, Y, Advance float64
}
diff --git a/ofdgo_font_shape.go b/ofdgo_font_shape.go
index 91a2f37..9bdbb99 100644
--- a/ofdgo_font_shape.go
+++ b/ofdgo_font_shape.go
@@ -18,6 +18,7 @@ import (
"bytes"
"cmp"
"fmt"
+ "math"
"slices"
"strconv"
"strings"
@@ -25,9 +26,12 @@ import (
"unicode"
"unicode/utf8"
+ "github.com/go-text/typesetting/di"
typefont "github.com/go-text/typesetting/font"
"github.com/go-text/typesetting/harfbuzz"
"github.com/go-text/typesetting/language"
+ "github.com/go-text/typesetting/shaping"
+ "golang.org/x/image/math/fixed"
xlanguage "golang.org/x/text/language"
)
@@ -36,6 +40,7 @@ type fontShaper struct {
metrics FontMetrics
shapeMu sync.Mutex
shapeFont *harfbuzz.Font
+ shapeFace *typefont.Face
shapeBuffer *harfbuzz.Buffer
}
@@ -46,8 +51,7 @@ func (f *fontShaper) ShapeText(value string, size float64) ([]ShapedGlyph, error
return f.ShapeTextWithOptions(value, size, TextShapeOptions{})
}
-// ShapeTextWithOptions 使用现有纯Go塑形引擎处理单一方向原文,保留逻辑簇和视觉位置
-// 不执行混合方向分段,调用方应将双向段落拆为单向文字对象
+// ShapeTextWithOptions 使用纯Go塑形引擎保留逻辑簇和视觉位置,可显式启用双向分段
// 入参: value 单行原文, size 毫米字号, options 塑形选项
// 返回: []ShapedGlyph 定位字形, error 字符或选项错误
func (f *fontShaper) ShapeTextWithOptions(value string, size float64, options TextShapeOptions) ([]ShapedGlyph, error) {
@@ -114,8 +118,12 @@ func (f *fontShaper) ShapeTextWithOptions(value string, size float64, options Te
return nil, fmt.Errorf("shaping font: %w", err)
}
f.shapeFont = harfbuzz.NewFont(face)
+ f.shapeFace = face
f.shapeBuffer = harfbuzz.NewBuffer()
}
+ if options.Bidi {
+ return f.shapeBidiText([]rune(value), size, props, features)
+ }
buffer := f.shapeBuffer
buffer.Clear()
buffer.Props = props
@@ -143,3 +151,75 @@ func (f *fontShaper) ShapeTextWithOptions(value string, size float64, options Te
slices.SortStableFunc(result, func(a, b ShapedGlyph) int { return cmp.Compare(a.Cluster, b.Cluster) })
return result, nil
}
+
+// shapingFace 固定双向排版的字体来源,不隐式替换字体
+type shapingFace struct{ face *typefont.Face }
+
+// ResolveFace 返回指定字体
+// 入参: char 字符
+// 返回: *typefont.Face 字体
+func (f shapingFace) ResolveFace(char rune) *typefont.Face { return f.face }
+
+// shapeBidiText 使用现有分段和行排列引擎生成单行双向字形
+// 入参: runes 原文, size 毫米字号, props 段落属性, features 字体特性
+// 返回: []ShapedGlyph 字形, error 错误信息
+func (f *fontShaper) shapeBidiText(runes []rune, size float64, props harfbuzz.SegmentProperties, features []harfbuzz.Feature) ([]ShapedGlyph, error) {
+ if len(runes) == 0 {
+ return nil, nil
+ }
+ direction := di.DirectionLTR
+ if props.Direction == harfbuzz.RightToLeft {
+ direction = di.DirectionRTL
+ }
+ input := shaping.Input{Text: runes, RunEnd: len(runes), Direction: direction, Face: f.shapeFace, Size: fixed.I(int(f.metrics.UnitsPerEm())), Script: props.Script, Language: props.Language}
+ for _, feature := range features {
+ input.FontFeatures = append(input.FontFeatures, shaping.FontFeature{Tag: feature.Tag, Value: feature.Value})
+ }
+ var segmenter shaping.Segmenter
+ var shaper shaping.HarfbuzzShaper
+ var wrapper shaping.LineWrapper
+ var runs []shaping.Output
+ var advance int64
+ for _, part := range segmenter.Split(input, shapingFace{f.shapeFace}) {
+ if props.Script != 0 {
+ part.Script = props.Script
+ }
+ if props.Language != "" {
+ part.Language = props.Language
+ }
+ run := shaper.Shape(part)
+ for _, glyph := range run.Glyphs {
+ advance += int64(glyph.Advance)
+ }
+ if advance < 0 || advance >= math.MaxInt32 {
+ return nil, fmt.Errorf("shaped line exceeds coordinate range")
+ }
+ runs = append(runs, run)
+ }
+ lines, truncated := wrapper.WrapParagraphF(shaping.WrapConfig{Direction: direction, DisableTrailingWhitespaceTrim: true}, fixed.Int26_6(math.MaxInt32), runes, shaping.NewSliceIterator(runs))
+ if truncated != 0 || len(lines) != 1 {
+ return nil, fmt.Errorf("shaping requires a single text line")
+ }
+ slices.SortFunc(lines[0], func(a, b shaping.Output) int { return cmp.Compare(a.VisualIndex, b.VisualIndex) })
+ unit := size / float64(f.metrics.UnitsPerEm()) / 64
+ var result []ShapedGlyph
+ var x float64
+ visual := make(map[int]int)
+ for _, run := range lines[0] {
+ for _, glyph := range run.Glyphs {
+ if glyph.GlyphID >= typefont.GID(f.metrics.NumGlyphs()) {
+ return nil, fmt.Errorf("invalid shaped glyph index %d", glyph.GlyphID)
+ }
+ cluster := glyph.TextIndex()
+ order, exists := visual[cluster]
+ if !exists {
+ order = len(visual)
+ visual[cluster] = order
+ }
+ result = append(result, ShapedGlyph{Glyph: uint16(glyph.GlyphID), Cluster: cluster, VisualOrder: order, X: (x + float64(glyph.XOffset)) * unit, Y: -float64(glyph.YOffset) * unit, Advance: float64(glyph.Advance) * unit})
+ x += float64(glyph.Advance)
+ }
+ }
+ slices.SortStableFunc(result, func(a, b ShapedGlyph) int { return cmp.Compare(a.Cluster, b.Cluster) })
+ return result, nil
+}
diff --git a/ofdgo_meta.go b/ofdgo_meta.go
index d26fca0..36f1fd7 100644
--- a/ofdgo_meta.go
+++ b/ofdgo_meta.go
@@ -52,6 +52,7 @@ type CustomDatas struct {
// CustomData 自定义数据项
type CustomData struct {
- Name string `xml:"Name,attr"`
- Value string `xml:",chardata"`
+ Name string `xml:"Name,attr"`
+ Value string `xml:",chardata"`
+ sourceIndex int
}
diff --git a/ofdgo_paint.go b/ofdgo_paint.go
index 5484fbe..583b92b 100644
--- a/ofdgo_paint.go
+++ b/ofdgo_paint.go
@@ -224,6 +224,19 @@ func (r *Renderer) parseShdColor(segments []ShdSegment, alpha *int) color.Color
// 返回: []ColorStop 后端无关的渐变分段
func (r *Renderer) GradientStops(segments []ShdSegment, alpha *int) []ColorStop {
var gradient []ColorStop
+ positions := gradientPositions(segments)
+ for i, segment := range segments {
+ segmentAlpha := mergeAlpha(segment.Color.Alpha, alpha)
+ gradient = append(gradient, ColorStop{Offset: positions[i], Color: r.ResolveColor(segment.Color.Value, segment.Color.Index, segment.Color.ColorSpace, segmentAlpha)})
+ }
+ return gradient
+}
+
+// gradientPositions 按显式锚点补全渐变分段位置,保留原顺序
+// 入参: segments 渐变分段
+// 返回: []float64 分段位置
+func gradientPositions(segments []ShdSegment) []float64 {
+ positions := make([]float64, len(segments))
position, step := 0.0, 0.0
for i, segment := range segments {
if !segment.positionMissing {
@@ -242,15 +255,10 @@ func (r *Renderer) GradientStops(segments []ShdSegment, alpha *int) []ColorStop
}
step = (end - position) / float64(next-i)
}
- offset := position
+ positions[i] = position
position += step
- segmentAlpha := mergeAlpha(segment.Color.Alpha, alpha)
- gradient = append(gradient, ColorStop{Offset: offset, Color: r.ResolveColor(segment.Color.Value, segment.Color.Index, segment.Color.ColorSpace, segmentAlpha)})
}
- if len(gradient) == 0 {
- return nil
- }
- return gradient
+ return positions
}
// colorToRGBA 转换颜色对象
diff --git a/ofdgo_text_shape.go b/ofdgo_text_shape.go
index 8363a69..ec71e83 100644
--- a/ofdgo_text_shape.go
+++ b/ofdgo_text_shape.go
@@ -119,9 +119,15 @@ func layoutShapedText(obj *TextObject, value string, options TextLayout, metrics
code := TextCode{X: ofdNumber(x), Y: ofdNumber(y), Value: escapeOFDText(string(chars))}
var dx, dy []string
var lastX, lastY float64
+ var visualShifts map[int]float64
+ if options.Shaping.Bidi {
+ visualShifts = bidiClusterShifts(glyphs, extra)
+ }
for j, glyph := range glyphs {
shift := extra[glyph.Cluster]
- if options.Shaping.Direction == "rtl" {
+ if options.Shaping.Bidi {
+ shift = visualShifts[glyph.Cluster]
+ } else if options.Shaping.Direction == "rtl" {
// 逻辑簇后的间距位于视觉左侧,整行不计末尾字距
shift = extra[len(chars)] - extra[glyph.Cluster] - options.LetterSpacing
}
@@ -162,6 +168,37 @@ func layoutShapedText(obj *TextObject, value string, options TextLayout, metrics
return nil
}
+// bidiClusterShifts 按视觉簇顺序累计字距和两端对齐产生的偏移
+// 入参: glyphs 逻辑顺序字形, extra 逻辑字符累计偏移
+// 返回: map[int]float64 各簇的视觉偏移
+func bidiClusterShifts(glyphs []ShapedGlyph, extra []float64) map[int]float64 {
+ type cluster struct {
+ index, order int
+ extra float64
+ }
+ var clusters []cluster
+ for i := 0; i < len(glyphs); {
+ end := i + 1
+ for end < len(glyphs) && glyphs[end].Cluster == glyphs[i].Cluster {
+ end++
+ }
+ next := len(extra) - 1
+ if end < len(glyphs) {
+ next = glyphs[end].Cluster
+ }
+ clusters = append(clusters, cluster{glyphs[i].Cluster, glyphs[i].VisualOrder, extra[next] - extra[glyphs[i].Cluster]})
+ i = end
+ }
+ slices.SortFunc(clusters, func(a, b cluster) int { return a.order - b.order })
+ shifts := make(map[int]float64, len(clusters))
+ var offset float64
+ for _, cluster := range clusters {
+ shifts[cluster.index] = offset
+ offset += cluster.extra
+ }
+ return shifts
+}
+
// ShapeText 使用固定选项塑形单行原文
// 入参: value 原文, size 毫米字号
// 返回: []ShapedGlyph 字形, error 塑形错误
diff --git a/ofdgo_writer.go b/ofdgo_writer.go
index 63397e8..4a8280f 100644
--- a/ofdgo_writer.go
+++ b/ofdgo_writer.go
@@ -375,45 +375,15 @@ func (e *Editor) usedResources() (fonts, images []editorResource, spaces []Color
for _, layer := range page.Content.Layer {
for _, object := range layer.Objects {
before, exists := original[editorObjectID(object)]
+ collectObjectReferences(object, used)
+ if e.source != nil && (!exists || !reflect.DeepEqual(before, object)) {
+ collectObjectReferences(object, promoted)
+ }
if origin := e.objectOrigin(editorObjectID(object)); origin != nil && origin.page != nil && (!exists || before.Type != object.Type || !reflect.DeepEqual(before.CompositeGraphicUnit, object.CompositeGraphicUnit)) {
for _, name := range origin.page.original.PageRes {
files[e.source.reader.ResPath(resolveResourcePath(origin.page.ref.BaseLoc, "", name))] = true
}
}
- for _, value := range []*FillColor{object.TextObject.FillColor, (*FillColor)(object.TextObject.StrokeColor), object.PathObject.FillColor, (*FillColor)(object.PathObject.StrokeColor)} {
- if value != nil && value.ColorSpace != "" {
- used[value.ColorSpace] = true
- if e.source != nil && (!exists || !reflect.DeepEqual(before, object)) {
- promoted[value.ColorSpace] = true
- }
- }
- }
- switch object.Type {
- case "CompositeObject", "CompositeGraphicUnit":
- collectCompositeReferences(object.CompositeGraphicUnit, used)
- if e.source != nil && (!exists || !reflect.DeepEqual(before.CompositeGraphicUnit, object.CompositeGraphicUnit)) {
- collectCompositeReferences(object.CompositeGraphicUnit, promoted)
- }
- case "TextObject":
- used[object.TextObject.DrawParam] = true
- used[object.TextObject.Font] = true
- if e.source != nil && (!exists || before.Type != object.Type || before.TextObject.Font != object.TextObject.Font) {
- promoted[object.TextObject.Font] = true
- }
- case "ImageObject":
- used[object.ImageObject.ResourceID] = true
- if e.source != nil && (!exists || before.Type != object.Type || before.ImageObject.ResourceID != object.ImageObject.ResourceID) {
- promoted[object.ImageObject.ResourceID] = true
- }
- if object.ImageObject.ImageMask != "" {
- used[object.ImageObject.ImageMask] = true
- if e.source != nil && (!exists || before.Type != object.Type || before.ImageObject.ImageMask != object.ImageObject.ImageMask) {
- promoted[object.ImageObject.ImageMask] = true
- }
- }
- case "PathObject":
- used[object.PathObject.DrawParam] = true
- }
}
}
}
@@ -663,6 +633,7 @@ func encodeOFDXML(encode func(*ofdXML)) ([]byte, error) {
func (x *ofdXML) object(object GraphicObject, root bool) {
var attrs ofdAttrs
var fill, stroke *FillColor
+ var actions []Action
if root {
attrs.add("xmlns:ofd", ofdNamespace)
}
@@ -676,6 +647,8 @@ func (x *ofdXML) object(object GraphicObject, root bool) {
attrs.add("Font", obj.Font)
attrs.number("Size", obj.Size)
attrs.number("HScale", obj.HScale)
+ attrs.number("VScale", obj.VScale)
+ attrs.add("Decoration", obj.Decoration)
attrs.number("Weight", float64(obj.Weight))
attrs.number("ReadDirection", float64(obj.ReadDirection))
attrs.number("CharDirection", float64(obj.CharDirection))
@@ -690,6 +663,7 @@ func (x *ofdXML) object(object GraphicObject, root bool) {
attrs.flag("Fill", obj.Fill)
attrs.alpha(obj.Alpha)
fill, stroke = obj.FillColor, (*FillColor)(obj.StrokeColor)
+ actions = obj.Actions
case "PathObject", "Path":
obj := object.PathObject
attrs.add("ID", obj.ID)
@@ -714,6 +688,7 @@ func (x *ofdXML) object(object GraphicObject, root bool) {
attrs.flag("Fill", obj.Fill)
attrs.alpha(obj.Alpha)
fill, stroke = obj.FillColor, (*FillColor)(obj.StrokeColor)
+ actions = obj.Actions
case "ImageObject":
obj := object.ImageObject
attrs.add("ID", obj.ID)
@@ -723,6 +698,7 @@ func (x *ofdXML) object(object GraphicObject, root bool) {
attrs.add("ImageMask", obj.ImageMask)
attrs.flag("Visible", obj.Visible)
attrs.alpha(obj.Alpha)
+ actions = obj.Actions
case "CompositeObject", "CompositeGraphicUnit":
obj := object.CompositeGraphicUnit
attrs.add("ID", obj.ID)
@@ -732,10 +708,13 @@ func (x *ofdXML) object(object GraphicObject, root bool) {
attrs.add("DrawParam", obj.DrawParam)
attrs.flag("Visible", obj.Visible)
attrs.alpha(obj.Alpha)
+ actions = obj.Actions
}
x.start(object.Type, attrs)
+ x.actions(actions)
if object.Type == "ImageObject" {
x.clips(object.ImageObject.Clips)
+ x.border(object.ImageObject.Border)
} else if object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" {
x.clips(object.CompositeGraphicUnit.Clips)
} else if object.Type == "PathObject" || object.Type == "Path" {
@@ -816,7 +795,7 @@ func (x *ofdXML) textCode(value string, attrs ofdAttrs) {
x.err = x.encoder.EncodeElement(content, xml.StartElement{Name: xml.Name{Local: "ofd:TextCode"}, Attr: attrs})
}
-// color 写出基本颜色
+// color 写出纯色、渐变和图案,保留颜色空间与透明度
// 入参: name 节点名, color 颜色
func (x *ofdXML) color(name string, color *FillColor) {
if color == nil {
@@ -830,5 +809,168 @@ func (x *ofdXML) color(name string, color *FillColor) {
}
attrs.alpha(color.Alpha)
x.start(name, attrs)
+ if color.AxialShd != nil {
+ shading := color.AxialShd
+ x.shading("AxialShd", shadingAttrs(shading.MapType, shading.MapUnit, shading.Extend, shading.StartPoint, shading.EndPoint), shading.Segment)
+ }
+ if color.RadialShd != nil {
+ shading := color.RadialShd
+ attrs := shadingAttrs(shading.MapType, shading.MapUnit, shading.Extend, shading.StartPoint, shading.EndPoint)
+ attrs.add("StartRadius", ofdNumber(shading.StartRadius))
+ attrs.add("EndRadius", ofdNumber(shading.EndRadius))
+ attrs.number("Eccentricity", shading.Eccentricity)
+ attrs.number("Angle", shading.Angle)
+ x.shading("RadialShd", attrs, shading.Segment)
+ }
+ if color.Pattern != nil {
+ x.pattern(color.Pattern)
+ }
x.end(name)
}
+
+// shadingAttrs 编码轴向和径向渐变的共同属性
+// 入参: mapType 映射方式, mapUnit 映射周期, extend 延伸方式, start、end 渐变端点
+// 返回: ofdAttrs 属性集合
+func shadingAttrs(mapType string, mapUnit float64, extend, start, end string) ofdAttrs {
+ var attrs ofdAttrs
+ attrs.add("MapType", mapType)
+ attrs.number("MapUnit", mapUnit)
+ attrs.add("Extend", extend)
+ attrs.add("StartPoint", start)
+ attrs.add("EndPoint", end)
+ return attrs
+}
+
+// shading 写出渐变分段,保留未显式设置的位置
+// 入参: name 渐变类型, attrs 属性, segments 色标
+func (x *ofdXML) shading(name string, attrs ofdAttrs, segments []ShdSegment) {
+ x.start(name, attrs)
+ for _, segment := range segments {
+ var attrs ofdAttrs
+ if !segment.positionMissing {
+ attrs.add("Position", ofdNumber(segment.Position))
+ }
+ x.start("Segment", attrs)
+ x.element("Color", segment.Color)
+ x.end("Segment")
+ }
+ x.end(name)
+}
+
+// element 写出具有标准XML标签的子树,显式声明OFD命名空间
+// 入参: name 元素名称, value 元素内容
+func (x *ofdXML) element(name string, value any) {
+ if x.err == nil {
+ x.err = x.encoder.EncodeElement(value, xml.StartElement{Name: xml.Name{Space: ofdNamespace, Local: name}})
+ }
+}
+
+// border 写出图片边框及其绘制颜色
+// 入参: border 边框,nil不输出节点
+func (x *ofdXML) border(border *ImageBorder) {
+ if border == nil {
+ return
+ }
+ var attrs ofdAttrs
+ if border.LineWidth != nil {
+ attrs.add("LineWidth", ofdNumber(*border.LineWidth))
+ }
+ attrs.number("HorizonalCornerRadius", border.HorizonalCornerRadius)
+ attrs.number("VerticalCornerRadius", border.VerticalCornerRadius)
+ attrs.number("DashOffset", border.DashOffset)
+ attrs.add("DashPattern", border.DashPattern)
+ x.start("Border", attrs)
+ x.color("BorderColor", (*FillColor)(border.BorderColor))
+ x.end("Border")
+}
+
+// pattern 写出图案单元,保留对象绘制顺序
+// 入参: pattern 图案
+func (x *ofdXML) pattern(pattern *Pattern) {
+ var attrs ofdAttrs
+ attrs.number("Width", pattern.Width)
+ attrs.number("Height", pattern.Height)
+ attrs.number("XStep", pattern.XStep)
+ attrs.number("YStep", pattern.YStep)
+ attrs.add("ReflectMethod", pattern.ReflectMethod)
+ attrs.add("RelativeTo", pattern.RelativeTo)
+ attrs.add("CTM", pattern.CTM)
+ x.start("Pattern", attrs)
+ x.start("CellContent", nil)
+ for _, object := range pattern.CellContent.Objects {
+ x.object(object, false)
+ }
+ x.end("CellContent")
+ x.end("Pattern")
+}
+
+// actions 写出对象动作及精确点击区域,不检查链接可达性
+// 入参: actions 动作列表
+func (x *ofdXML) actions(actions []Action) {
+ if len(actions) == 0 {
+ return
+ }
+ x.start("Actions", nil)
+ for _, action := range actions {
+ var attrs ofdAttrs
+ attrs.add("Event", action.Event)
+ x.start("Action", attrs)
+ if action.Region != nil {
+ x.start("Region", nil)
+ for _, area := range action.Region.Area {
+ x.start("Area", ofdAttrs{{Name: xml.Name{Local: "Start"}, Value: area.Start}})
+ for _, command := range area.Command {
+ var attrs ofdAttrs
+ for _, pair := range [][2]string{{"Point1", command.Point1}, {"Point2", command.Point2}, {"Point3", command.Point3}, {"EllipseSize", command.EllipseSize}, {"RotationAngle", command.RotationAngle}, {"LargeArc", command.LargeArc}, {"SweepDirection", command.SweepDirection}, {"EndPoint", command.EndPoint}} {
+ attrs.add(pair[0], pair[1])
+ }
+ x.start(command.Type, attrs)
+ x.end(command.Type)
+ }
+ x.end("Area")
+ }
+ x.end("Region")
+ }
+ if action.Goto != nil {
+ x.start("Goto", nil)
+ if dest := action.Goto.Dest; dest != nil {
+ attrs := ofdAttrs{{Name: xml.Name{Local: "Type"}, Value: dest.Type}, {Name: xml.Name{Local: "PageID"}, Value: dest.PageID}}
+ switch dest.Type {
+ case "XYZ":
+ attrs.add("Left", ofdNumber(dest.Left))
+ attrs.add("Top", ofdNumber(dest.Top))
+ attrs.add("Zoom", ofdNumber(dest.Zoom))
+ case "FitH":
+ attrs.add("Top", ofdNumber(dest.Top))
+ case "FitV":
+ attrs.add("Left", ofdNumber(dest.Left))
+ case "FitR":
+ attrs.add("Left", ofdNumber(dest.Left))
+ attrs.add("Right", ofdNumber(dest.Right))
+ attrs.add("Top", ofdNumber(dest.Top))
+ attrs.add("Bottom", ofdNumber(dest.Bottom))
+ }
+ x.start("Dest", attrs)
+ x.end("Dest")
+ }
+ if action.Goto.Bookmark != nil {
+ x.element("Bookmark", action.Goto.Bookmark)
+ }
+ x.end("Goto")
+ }
+ if action.URI != nil {
+ x.element("URI", action.URI)
+ }
+ if action.GotoA != nil {
+ x.element("GotoA", action.GotoA)
+ }
+ if action.Sound != nil {
+ x.element("Sound", action.Sound)
+ }
+ if action.Movie != nil {
+ x.element("Movie", action.Movie)
+ }
+ x.end("Action")
+ }
+ x.end("Actions")
+}