From d7cd3d03b552009de8dbc36e5b50dbbca365cfee Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Thu, 24 Sep 2026 11:58:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BC=96=E8=BE=91=E8=83=BD=E5=8A=9B):=20?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E5=9B=BE=E6=96=87=E7=BC=96=E8=BE=91=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=EF=BC=8C=E4=BC=98=E5=8C=96=E7=95=8C=E9=9D=A2=E4=B8=8E?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E5=8F=AF=E9=9D=A0=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/index.html | 26 ++++ assets/webui/ofdgo.css | 52 ++++++++ assets/webui/ofdgo.js | 116 ++++++++++++++++- internal/webui/wasm.go | 109 +++++++++++++++- internal/webui/webui.go | 45 ++++--- ofdgo_editor.go | 13 +- ofdgo_editor_color.go | 201 ++++++++++++++++++++++++++++- ofdgo_editor_composite.go | 21 +-- ofdgo_editor_composite_insert.go | 20 ++- ofdgo_editor_link.go | 85 ++++++++++++- ofdgo_editor_metadata.go | 93 +++++++++++--- ofdgo_editor_open.go | 14 ++ ofdgo_editor_style.go | 75 ++++++++++- ofdgo_editor_text.go | 57 +++++++-- ofdgo_font_backend.go | 6 +- ofdgo_font_shape.go | 84 +++++++++++- ofdgo_meta.go | 5 +- ofdgo_paint.go | 22 +++- ofdgo_text_shape.go | 39 +++++- ofdgo_writer.go | 212 ++++++++++++++++++++++++++----- 20 files changed, 1158 insertions(+), 137 deletions(-) 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") +}