diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 5bd1067..5b3e75a 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -1647,7 +1647,7 @@ async function changeTextStyle(color) { return; } if (item.items) { - if (color ? !canEditObject(item, "update") : !canEditObject(item, "layoutKnown") || !canEditObject(item, "reflow")) return; + if (color ? !canEditObject(item, "paint") : !canEditObject(item, "layoutKnown") || !canEditObject(item, "reflow")) return; if (!color && !el.textSize.value) return; if (size !== item.size || fill !== null) { await changeDocument("ofdgoStyleText", { ...item, id: item.items.map(member => member.id) }, null, color ? 0 : size, fill); @@ -1662,7 +1662,7 @@ async function changeTextStyle(color) { return; } if (size !== item.size || fill !== null) { - if (color ? !canEditObject(item, "update") : !confirmTextReflow(item)) { + if (color ? !canEditObject(item, "paint") : !confirmTextReflow(item)) { updateObjectControls(item, true); return; } @@ -1693,7 +1693,7 @@ async function changeShapeStyle({ target }) { if (!state.editing || document.body.hasAttribute("aria-busy")) { return; } - if (canvasEditor.selected && (!item || !canEditObject(item, "update"))) return; + if (canvasEditor.selected && (!item || !canEditObject(item, "paint"))) return; if (target === el.shapeWidth && (!el.shapeWidth.checkValidity() || Number(el.shapeWidth.value) <= 0)) { el.shapeWidth.value = item ? item.lineWidth === undefined ? "" : String(displayPoints(item.lineWidth)) : "1"; return; @@ -1741,7 +1741,7 @@ function updateDrawingControls() { el.eraseButton.disabled = el.eraseMode.disabled = disabled; el.eraseButton.setAttribute("aria-pressed", String(tool.startsWith("erase-"))); el.selectObjectButton.setAttribute("aria-pressed", String(canvasEditor.enabled && !tool)); - const styleDisabled = disabled || (canvasEditor.selected ? !path || !canEditObject(path, "update") : !pageCan("insert")); + const styleDisabled = disabled || (canvasEditor.selected ? !path || !canEditObject(path, "paint") : !pageCan("insert")); el.shapeFill.disabled = el.shapeStroke.disabled = styleDisabled || line; el.shapeFillColor.disabled = styleDisabled || line || !el.shapeFill.checked && !el.shapeFill.indeterminate; el.shapeStrokeColor.disabled = el.shapeWidth.disabled = styleDisabled || !line && !el.shapeStroke.checked && !el.shapeStroke.indeterminate; @@ -5478,7 +5478,7 @@ function updateObjectControls(item, reset = false) { const textDisabled = !state.editing || !state.ready || state.exporting || Boolean(item?.items && !selection); fontPicker.setDisabled(textDisabled || text !== state.textDefaults && !item.draft && !canEditObject(item, "replaceFont")); el.textSize.disabled = textDisabled || text !== state.textDefaults && !item.draft && (!canEditObject(item, "reflow") || Boolean(item?.items) && !canEditObject(item, "layoutKnown")); - el.textColor.disabled = textDisabled || text !== state.textDefaults && !item.draft && !canEditObject(item, "update"); + el.textColor.disabled = textDisabled || text !== state.textDefaults && !item.draft && !canEditObject(item, "paint"); el.textFontAdd.disabled = !state.editing || !state.ready || state.exporting; el.textAlign.disabled = el.textWrap.disabled = el.textLineHeight.disabled = el.textSpacing.disabled = el.textSize.disabled || Boolean(item?.items || item?.draft) || Boolean(canvasEditor.input); el.paragraphButton.disabled = el.textAlign.disabled; @@ -5516,7 +5516,7 @@ function updateObjectControls(item, reset = false) { if (path && (reset || document.activeElement !== input)) input.value = path[key] || "#000000"; } el.shapeWidth.placeholder = path?.items && path.lineWidth === undefined ? "混合" : ""; - if (path && canEditObject(path, "update")) { + if (path && canEditObject(path, "paint")) { if (reset || document.activeElement !== el.shapeWidth) { el.shapeWidth.value = path.lineWidth === undefined ? "" : displayPoints(path.lineWidth); } diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index d257b54..aad3c6c 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -190,7 +190,9 @@ export function objectEditReason(item) { if (!reason) return ""; let message = labels[object.capabilities.reasonCode] || "对象特性暂不支持"; if (object.capabilities.missingGlyphs) message = missingGlyphMessage(object.capabilities.missingGlyphs); - return `${object.capabilities.transform ? "部分操作受限" : "暂不可编辑"}:${message}`; + const available = object.capabilities.transform ? object.capabilities.paint ? "可移动、改色" : "可移动" + : object.capabilities.copy && object.capabilities.delete ? "可复制、删除" : "暂不可编辑"; + return `${available}:${message}`; }); return [...new Set(reasons.filter(Boolean))].join(";"); } @@ -276,7 +278,7 @@ export class CanvasEditor { } node.tabIndex = 0; node.setAttribute("role", "button"); - node.setAttribute("aria-label", { ImageObject: "图片对象", TextObject: "文字对象", PathObject: "图形对象" }[object.type]); + node.setAttribute("aria-label", { ImageObject: "图片对象", TextObject: "文字对象", PathObject: "图形对象", CompositeObject: "复合对象", CompositeGraphicUnit: "复合对象" }[object.type]); const item = { ...object, index, page: { width: page.width, height: page.height }, node, surface }; item.artwork = artwork.get(object.id)?.node; if (object.type === "PathObject" && (object.shape || object.outline)) { diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 62f9db3..6318fd1 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -25,6 +25,7 @@ import ( "errors" "fmt" "image" + "image/color" "io" "math" "strconv" @@ -726,6 +727,8 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { id = object.ImageObject.ID case "PathObject": id = object.PathObject.ID + case "CompositeObject", "CompositeGraphicUnit": + id = object.CompositeGraphicUnit.ID default: continue } @@ -749,7 +752,7 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { return nil, err } item := map[string]any{"id": id, "type": object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "position": position.Index, "count": position.Count, "container": position.Container, - "capabilities": map[string]any{"update": capability.Update, "replaceFont": capability.ReplaceFont, "reflow": capability.Reflow, "layoutKnown": capability.LayoutKnown, "transform": capability.Transform, "arrange": capability.Arrange, "copy": capability.Copy, "delete": capability.Delete, "order": capability.Order, "reason": capability.Reason, "reasonCode": string(capability.ReasonCode)}} + "capabilities": map[string]any{"update": capability.Update, "paint": capability.Paint, "replaceFont": capability.ReplaceFont, "reflow": capability.Reflow, "layoutKnown": capability.LayoutKnown, "transform": capability.Transform, "arrange": capability.Arrange, "copy": capability.Copy, "delete": capability.Delete, "order": capability.Order, "reason": capability.Reason, "reasonCode": string(capability.ReasonCode)}} if missing := capability.MissingGlyphs; missing != nil { item["capabilities"].(map[string]any)["missingGlyphs"] = map[string]any{"fontID": missing.FontID, "characters": missing.Characters} } @@ -761,6 +764,8 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { alpha = object.ImageObject.Alpha case "PathObject": alpha = object.PathObject.Alpha + case "CompositeObject", "CompositeGraphicUnit": + alpha = object.CompositeGraphicUnit.Alpha } item["alpha"] = 255 if alpha != nil { @@ -780,14 +785,14 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { } else if outline, err := path.Outline(); err == nil { item["outline"] = outline } - if capability.Update { + if capability.Paint { item["fill"], item["stroke"] = path.Fill != nil && *path.Fill, path.Stroke == nil || *path.Stroke item["fillColor"] = editorColorHex(path.FillColor) item["strokeColor"] = editorColorHex((*ofdgo.FillColor)(path.StrokeColor)) item["lineWidth"] = path.LineWidth * editorPathScale(path) } } - if object.Type == "PathObject" || object.Type == "ImageObject" { + if object.Type == "PathObject" || object.Type == "ImageObject" || object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" { contours, err := currentSession.Renderer.ObjectContours(object, layer.DrawParam) if err != nil { return nil, err @@ -837,7 +842,7 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { second, _ := strconv.ParseFloat(codes[1].Y, 64) item["lineHeight"] = second - first } - if capability.Update { + if capability.Paint { item["color"] = editorColorHex(object.TextObject.FillColor) } } @@ -1529,10 +1534,10 @@ func setTextColor(object *ofdgo.TextObject, value string) error { return setEditorColor(object.FillColor, value) } -// setEditorColor 写入浏览器RGB色值,保留其他颜色属性 -// 入参: color 颜色, value 十六进制色值 +// setEditorColor 写入当前文档的RGB纯色,保留透明度并替换原颜色空间和索引 +// 入参: fill 颜色, value 十六进制色值 // 返回: error 错误信息 -func setEditorColor(color *ofdgo.FillColor, value string) error { +func setEditorColor(fill *ofdgo.FillColor, value string) error { if len(value) != 7 || value[0] != '#' { return fmt.Errorf("invalid RGB color %q", value) } @@ -1540,19 +1545,24 @@ func setEditorColor(color *ofdgo.FillColor, value string) error { if err != nil { return err } - color.Value = fmt.Sprintf("%d %d %d", rgb[0], rgb[1], rgb[2]) + converted, err := currentEditor.RGBColor(color.NRGBA{R: rgb[0], G: rgb[1], B: rgb[2], A: 255}) + if err != nil { + return err + } + converted.Alpha = fill.Alpha + *fill = *converted return nil } -// editorColorHex 将创作对象的RGB颜色转换为浏览器色值 +// editorColorHex 将当前文档纯色转换为浏览器色值 // 入参: color 颜色 // 返回: string 十六进制色值 -func editorColorHex(color *ofdgo.FillColor) string { - var r, g, b int - if color != nil { - fmt.Sscan(color.Value, &r, &g, &b) +func editorColorHex(value *ofdgo.FillColor) string { + converted, err := currentEditor.Color(value) + if err != nil { + return "" } - return fmt.Sprintf("#%02x%02x%02x", r, g, b) + return fmt.Sprintf("#%02x%02x%02x", converted.R, converted.G, converted.B) } // editorPathScale 获取路径线宽的页面缩放比例 diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 584deb5..5e63508 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -63,6 +63,7 @@ type editorResource struct { data []byte font *Font image *MultiMedia + space *ColorSpace subset *editorFontSubset } @@ -781,7 +782,7 @@ func (e *Editor) prepareObject(id string, object GraphicObject) (GraphicObject, return GraphicObject{}, fmt.Errorf("alpha must be between 0 and 255") } for _, color := range []*FillColor{fill, stroke} { - if err := creationColor(color); err != nil { + if err := e.editorColor(color); err != nil { return GraphicObject{}, &EditError{Code: EditUnsupportedColor, Err: err} } } @@ -890,6 +891,9 @@ func (e *Editor) TransformObject(page int, id string, dx, dy, scale float64) err // 入参: object 对象副本, dx 横向位移, dy 纵向位移, scale 正缩放比例 // 返回: GraphicObject 变换后的对象, error 错误信息 func transformEditorObject(object GraphicObject, dx, dy, scale float64) (GraphicObject, error) { + if object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" || object.Type == "TextObject" && (len(object.TextObject.CGTransform) != 0 || object.TextObject.VScale != 0 || object.TextObject.Decoration != "") { + return transformEditorMatrix(object, Matrix{a: scale, d: scale, e: dx, f: dy}), nil + } var boundary, ctm *string switch object.Type { case "TextObject": diff --git a/ofdgo_editor_batch.go b/ofdgo_editor_batch.go index c8223f6..2d36a09 100644 --- a/ofdgo_editor_batch.go +++ b/ofdgo_editor_batch.go @@ -82,18 +82,14 @@ func (e *Editor) CopyObjects(page int, objects []GraphicObject, dx, dy float64) if len(objects) == 0 { return nil, nil } - if !e.sourceRGB() { - return nil, fmt.Errorf("inserting objects requires an RGB default color space") - } if err := e.prepareSourceIDs(); err != nil { return nil, err } prepared := make([]GraphicObject, len(objects)) result := make([]string, len(objects)) for i, object := range objects { - origin := e.objectOrigin(editorObjectID(object)) result[i] = strconv.Itoa(e.maxID + i + 1) - object, err := e.prepareObject(result[i], object) + object, err := e.prepareCopiedObject(result[i], object) if err != nil { return nil, err } @@ -102,14 +98,9 @@ func (e *Editor) CopyObjects(page int, objects []GraphicObject, dx, dy float64) if err != nil { return nil, err } - codes := object.TextObject.TextCode - object, err = e.prepareObject(result[i], object) - if err != nil { + if err = validateEditorGeometry(object); err != nil { return nil, err } - if origin != nil { - object.TextObject.TextCode = codes - } } prepared[i] = object } @@ -308,7 +299,7 @@ func (e *Editor) DistributeObjects(page int, ids []string, axis string) error { if err != nil { return err } - object, err = transformEditorObject(object, dx, dy, 1) + object, err = e.transformObject(object, dx, dy, 1) if err != nil { return err } @@ -356,16 +347,27 @@ func (e *Editor) updateObjects(page int, objects []GraphicObject, preserveConten } after := make([]GraphicObject, len(objects)) for i, object := range objects { - if e.originalPage(page) { + preserved := preserveContent + origin := e.objectOrigin(ids[i]) + if origin != nil { capability, err := e.ObjectCapabilities(page, ids[i]) if err != nil { return err } - if !capability.Transform || !preserveContent && !e.sourceRGB() { + if !capability.Transform { return fmt.Errorf("object %q is read-only for this operation: %w", ids[i], capability.editError()) } + if !preserved && !capability.Update && editorPreservedObject(before[i], object) && capability.Paint { + if err := e.validatePreservedAppearance(before[i], object); err != nil { + return err + } + preserved = true + } + if !preserved && !editorXMLSupported(origin.node) { + return capability.editError() + } } - if preserveContent && e.originalPage(page) { + if preserved && origin != nil { if err := validateEditorGeometry(object); err != nil { return err } @@ -423,7 +425,7 @@ func (e *Editor) TransformObjects(page int, ids []string, dx, dy, scale float64) if err != nil { return err } - objects[i], err = transformEditorObject(object, dx, dy, scale) + objects[i], err = e.transformObject(object, dx, dy, scale) if err != nil { return err } @@ -476,7 +478,7 @@ func (e *Editor) AlignObjects(page int, ids []string, alignment string) error { if err != nil { return err } - objects[i], err = transformEditorObject(objects[i], dx, dy, 1) + objects[i], err = e.transformObject(objects[i], dx, dy, 1) if err != nil { return err } @@ -576,10 +578,17 @@ func (e *Editor) objectBounds(page int, objects []GraphicObject) ([]Box, error) return nil, err } defer reader.Close() + if _, err := reader.PageContentByIndex(page); err != nil { + return nil, err + } renderer := NewRenderer(reader) boxes := make([]Box, len(objects)) for i, object := range objects { - boxes[i], err = renderer.ObjectBounds(object, "") + layer, _, findErr := e.findObject(page, editorObjectID(object)) + if findErr != nil { + return nil, findErr + } + boxes[i], err = renderer.ObjectBounds(object, layer.DrawParam) if err != nil { return nil, err } diff --git a/ofdgo_editor_color.go b/ofdgo_editor_color.go new file mode 100644 index 0000000..7573122 --- /dev/null +++ b/ofdgo_editor_color.go @@ -0,0 +1,171 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "fmt" + "image/color" + "maps" + "slices" + "strconv" + "strings" +) + +// Color 解析当前文档的纯色,支持RGB、灰度、CMYK及调色板,不修改原颜色 +// 入参: value 颜色,nil表示默认黑色 +// 返回: color.NRGBA 非预乘颜色, error 错误信息 +func (e *Editor) Color(value *FillColor) (color.NRGBA, error) { + space := ColorSpace{Type: "RGB", BitsPerComponent: 8} + if value == nil { + return color.NRGBA{A: 255}, nil + } + if value.Pattern != nil || value.AxialShd != nil || value.RadialShd != nil || value.unsupported { + return color.NRGBA{}, fmt.Errorf("color is not a supported solid color") + } + id := value.ColorSpace + if id == "" && e.source != nil && e.source.document.CommonData.DefaultCS != 0 { + id = strconv.Itoa(e.source.document.CommonData.DefaultCS) + } + if id != "" { + var found *ColorSpace + if e.source != nil { + found = e.source.reader.colorSpaceCache[id] + } + for _, resource := range e.resources { + if resource.space != nil && resource.space.ID == id { + found = resource.space + break + } + } + if found == nil { + return color.NRGBA{}, fmt.Errorf("color space %q not found", id) + } + space = *found + } + bits := space.BitsPerComponent + if bits == 0 { + bits = 8 + } + if bits != 1 && bits != 2 && bits != 4 && bits != 8 && bits != 16 { + return color.NRGBA{}, fmt.Errorf("unsupported color depth %d", bits) + } + count := 3 + switch space.Type { + case "RGB": + case "GRAY": + count = 1 + case "CMYK": + count = 4 + default: + return color.NRGBA{}, fmt.Errorf("unsupported color space %q", space.Type) + } + text := value.Value + if strings.TrimSpace(text) == "" && value.Index != nil { + if *value.Index < 0 || *value.Index >= len(space.Palette) { + return color.NRGBA{}, fmt.Errorf("color palette index out of range") + } + text = space.Palette[*value.Index] + } + fields := strings.Fields(text) + if len(fields) != count { + return color.NRGBA{}, fmt.Errorf("expected %d color components", count) + } + var components [4]uint8 + limit := 1< limit { + return color.NRGBA{}, fmt.Errorf("invalid color component %q", field) + } + components[i] = uint8((v*255 + limit/2) / limit) + } + r, g, b := components[0], components[1], components[2] + if space.Type == "GRAY" { + g, b = r, r + } else if space.Type == "CMYK" { + r, g, b = color.CMYKToRGB(components[0], components[1], components[2], components[3]) + } + a := 255 + if value.Alpha != nil { + a = *value.Alpha + if a < 0 || a > 255 { + return color.NRGBA{}, fmt.Errorf("color alpha must be between 0 and 255") + } + } + return color.NRGBA{R: r, G: g, B: b, A: uint8(a)}, nil +} + +// RGBColor 创建浏览器等调用方使用的RGB纯色,非RGB文档按需注册独立颜色空间 +// 不修改文档默认颜色空间,未使用的新增颜色空间不写出 +// 入参: value 非预乘颜色 +// 返回: *FillColor OFD颜色, error 错误信息 +func (e *Editor) RGBColor(value color.NRGBA) (*FillColor, error) { + result := &FillColor{Value: fmt.Sprintf("%d %d %d", value.R, value.G, value.B)} + if value.A != 255 { + alpha := int(value.A) + result.Alpha = &alpha + } + if e.sourceRGB() { + return result, nil + } + for _, id := range slices.Sorted(maps.Keys(e.source.reader.colorSpaceCache)) { + space := e.source.reader.colorSpaceCache[id] + if space.Type == "RGB" && (space.BitsPerComponent == 0 || space.BitsPerComponent == 8) && len(space.Palette) == 0 { + result.ColorSpace = id + return result, nil + } + } + for _, resource := range e.resources { + if resource.space != nil { + result.ColorSpace = resource.space.ID + return result, nil + } + } + if err := e.prepareSourceIDs(); err != nil { + return nil, err + } + result.ColorSpace = e.nextID() + e.resources = append(e.resources, editorResource{space: &ColorSpace{ID: result.ColorSpace, Type: "RGB", BitsPerComponent: 8}}) + return result, nil +} + +// editorPaintable 判断文字与路径颜色能否在保留其他字段的前提下修改 +// 入参: object 图形对象 +// 返回: bool 是否支持纯色编辑 +func (e *Editor) editorPaintable(object GraphicObject) bool { + var fill, stroke *FillColor + switch object.Type { + case "TextObject": + fill, stroke = object.TextObject.FillColor, (*FillColor)(object.TextObject.StrokeColor) + case "PathObject": + fill, stroke = object.PathObject.FillColor, (*FillColor)(object.PathObject.StrokeColor) + default: + return false + } + for _, value := range []*FillColor{fill, stroke} { + if _, err := e.Color(value); err != nil { + return false + } + } + return true +} + +// editorColor 校验当前文档的颜色空间和分量 +// 入参: value 颜色 +// 返回: error 错误信息 +func (e *Editor) editorColor(value *FillColor) error { + _, err := e.Color(value) + return err +} diff --git a/ofdgo_editor_erase.go b/ofdgo_editor_erase.go index f12b1de..edd8a31 100644 --- a/ofdgo_editor_erase.go +++ b/ofdgo_editor_erase.go @@ -184,7 +184,7 @@ func (e *Editor) eraseObjects(page int, ids []string, box Box, points []Point, r } // editorObjectClips 获取基本对象的裁剪字段 -// 入参: object 文字、路径或图片对象 +// 入参: object 文字、路径、图片或复合对象 // 返回: **Clips 裁剪字段地址,不支持的对象返回nil func editorObjectClips(object *GraphicObject) **Clips { switch object.Type { @@ -194,6 +194,8 @@ func editorObjectClips(object *GraphicObject) **Clips { return &object.PathObject.Clips case "ImageObject": return &object.ImageObject.Clips + case "CompositeObject", "CompositeGraphicUnit": + return &object.CompositeGraphicUnit.Clips } return nil } diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index 3889847..73deb6f 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -60,9 +60,11 @@ type editorSourcePage struct { // ObjectCapabilities 已有对象可执行的操作,Reason说明受限原因 // Update表示完整替换内容,ReplaceFont表示可显式替换文字字体,Transform表示保留文字定位的几何变换,Order仅限同一图层或末级页块 // Arrange表示可准确度量对齐与旋转范围;Reflow表示可重排文字,LayoutKnown表示段落选项可恢复 +// Paint表示可独立修改纯色填充和描边,不要求重新排版文字 // MissingGlyphs提供缺字导致操作受限时的结构化诊断 type ObjectCapabilities struct { Update bool + Paint bool ReplaceFont bool Reflow bool LayoutKnown bool @@ -128,9 +130,9 @@ func (r *Reader) Editor() (*Editor, error) { } e := NewEditor() e.Info, e.maxID = cloneEditorData(*info), maximum - directory := path.Join(reader.RootDir, "Edit") + directory := path.Join(cleanPackagePath(reader.RootDir), "Edit") for i := 1; editorDirectoryExists(reader, directory); i++ { - directory = path.Join(reader.RootDir, "Edit_"+strconv.Itoa(i)) + directory = path.Join(cleanPackagePath(reader.RootDir), "Edit_"+strconv.Itoa(i)) } e.source = &editorSource{reader: reader, document: doc, info: cloneEditorData(*info), directory: directory, pages: make(map[string]*editorSourcePage), origins: make(map[string]*editorObjectOrigin)} for _, page := range doc.Pages.Page { @@ -322,7 +324,7 @@ func (e *Editor) loadSourcePage(index int) error { if node := nodes[id]; node != nil { origin := &editorObjectOrigin{page: source, node: node, object: object} e.source.origins[id] = origin - if editorXMLSupported(node) { + if editorXMLTransformable(node) { resolved, err := e.resolveEditorStyle(object, layer.DrawParam) if err != nil { origin.reason = err @@ -339,7 +341,7 @@ func (e *Editor) loadSourcePage(index int) error { } // ObjectCapabilities 获取对象可执行的操作,不支持的内容保持原文,不强制转换 -// 字体缺失或子集不包含原文时仍可移动、删除,不允许重新排版或复制 +// 字体缺失或子集不包含原文时仍可保真移动、复制和删除,不自动重新排版 // 显式提供可用字体后可通过UpdateObject替换;原文布局未知时不自动推断段落选项 // 入参: page 页面索引, id 对象标识 // 返回: ObjectCapabilities 操作能力, error 错误信息 @@ -349,47 +351,51 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er return ObjectCapabilities{}, err } object := layer.Objects[index] - all := ObjectCapabilities{Update: true, ReplaceFont: object.Type == "TextObject", Reflow: object.Type == "TextObject" && object.TextObject.ReadDirection == 0 && object.TextObject.CharDirection == 0, LayoutKnown: object.Type == "TextObject" && (e.objectOrigin(id) == nil || object.TextObject.layout != nil), Transform: true, Arrange: true, Copy: true, Delete: true, Order: true} - if !e.originalPage(page) { + all := ObjectCapabilities{Update: true, Paint: object.Type == "TextObject" || object.Type == "PathObject", ReplaceFont: object.Type == "TextObject", Reflow: object.Type == "TextObject" && object.TextObject.ReadDirection == 0 && object.TextObject.CharDirection == 0, LayoutKnown: object.Type == "TextObject" && (e.objectOrigin(id) == nil || object.TextObject.layout != nil), Transform: true, Arrange: true, Copy: true, Delete: true, Order: true} + origin := e.objectOrigin(id) + if origin == nil { return all, nil } - source := e.source.pages[e.pages[page].ID] - node := source.nodes[id] - if node == nil { - return all, nil - } - if !editorXMLSupported(node) { + node := origin.node + if !editorXMLTransformable(node) { return ObjectCapabilities{Reason: "object uses unsupported editing features", ReasonCode: EditUnsupportedObject}, nil } - if origin := e.objectOrigin(id); origin.reason != nil { - return ObjectCapabilities{Reason: origin.reason.Error(), ReasonCode: editReason(origin.reason)}, nil + if origin.reason != nil && editReason(origin.reason) == EditUnsupportedStyle { + return ObjectCapabilities{Reason: origin.reason.Error(), ReasonCode: EditUnsupportedStyle}, nil } - for parent := node.parent; parent.name.Local != "Content"; parent = parent.parent { - allowed := "ID" - if parent.name.Local == "Layer" { - allowed += " Type DrawParam" - } - if !editorXMLAttributes(parent, allowed) { - return ObjectCapabilities{Reason: "object container uses unsupported editing features", ReasonCode: EditUnsupportedContainer}, nil - } + if !editorXMLContainersSupported(node) { + return ObjectCapabilities{Reason: "object container uses unsupported editing features", ReasonCode: EditUnsupportedContainer}, nil } if err := validateEditorGeometry(object); err != nil { return ObjectCapabilities{Reason: err.Error(), ReasonCode: EditInvalidObject}, nil } all.Order = editorContainerOrderable(node.parent) - if _, err := e.prepareObject(id, object); err != nil { - all.Update, all.Reflow, all.Copy, all.Reason = false, false, false, err.Error() + all.Copy = editorXMLCopyable(node) && origin.reason == nil + if object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" { + for parent := node.parent; parent != nil; parent = parent.parent { + if parent.name.Local == "Layer" && parent.attr("DrawParam") != "" { + all.Copy = false + } + } + } + if origin.reason != nil { + all.Update, all.Reflow, all.ReplaceFont, all.Paint = false, false, false, false + all.Reason, all.ReasonCode = origin.reason.Error(), editReason(origin.reason) + } else if !editorXMLSupported(node) { + all.Update, all.Reflow, all.ReplaceFont = false, false, false + all.Reason, all.ReasonCode = "content can only be edited with its original structure preserved", EditUnsupportedObject + } else if _, err := e.prepareObject(id, object); err != nil { + all.Update, all.Reflow, all.Reason = false, false, err.Error() all.ReasonCode = editReason(err) errors.As(err, &all.MissingGlyphs) } - if !e.sourceRGB() { - all.ReplaceFont = false - all.Update, all.Reflow, all.Copy, all.Reason = false, false, false, "document uses a non-RGB default color space" - all.MissingGlyphs = nil - all.ReasonCode = EditUnsupportedColor + all.Paint = all.Paint && e.editorPaintable(object) + if object.Type == "ImageObject" && object.ImageObject.Border != nil { + all.Transform, all.Arrange = false, false + all.Reason, all.ReasonCode = "image border transformations are not supported", EditUnsupportedObject } if object.Type == "TextObject" { - all.Arrange = all.Update + all.Arrange = all.Update || e.editorTextMeasurable(object.TextObject) if _, err := object.TextObject.TextFrame(); err != nil { all.Reflow = false } @@ -407,12 +413,147 @@ func (e *Editor) objectOrigin(id string) *editorObjectOrigin { return e.source.origins[id] } +// editorPreservedObject 判断对象是否仅改变标识、几何和可独立写回的外观字段 +// 入参: before 原始快照, after 新快照 +// 返回: bool 是否保留原内容 +func editorPreservedObject(before, after GraphicObject) bool { + if before.Type != after.Type { + return false + } + before = cloneEditorData(before) + switch before.Type { + case "TextObject": + a, b := &before.TextObject, after.TextObject + a.ID, a.Boundary, a.CTM, a.Alpha = b.ID, b.Boundary, b.CTM, b.Alpha + a.Fill, a.Stroke, a.FillColor, a.StrokeColor, a.LineWidth = b.Fill, b.Stroke, b.FillColor, b.StrokeColor, b.LineWidth + case "PathObject": + a, b := &before.PathObject, after.PathObject + a.ID, a.Boundary, a.CTM, a.Alpha = b.ID, b.Boundary, b.CTM, b.Alpha + a.Fill, a.Stroke, a.FillColor, a.StrokeColor, a.LineWidth = b.Fill, b.Stroke, b.FillColor, b.StrokeColor, b.LineWidth + a.DashOffset, a.DashPattern, a.Cap, a.Join = b.DashOffset, b.DashPattern, b.Cap, b.Join + case "ImageObject": + a, b := &before.ImageObject, after.ImageObject + a.ID, a.Boundary, a.CTM, a.Alpha = b.ID, b.Boundary, b.CTM, b.Alpha + case "CompositeObject", "CompositeGraphicUnit": + a, b := &before.CompositeGraphicUnit, after.CompositeGraphicUnit + a.ID, a.Boundary, a.CTM, a.Alpha = b.ID, b.Boundary, b.CTM, b.Alpha + default: + return false + } + a, b := *editorObjectClips(&before), *editorObjectClips(&after) + if a == nil { + *editorObjectClips(&before) = b + } else if b != nil && len(a.Clip) <= len(b.Clip) { + a.Clip = append(a.Clip, b.Clip[len(a.Clip):]...) + for i := range a.Clip { + if len(a.Clip[i].Area) == len(b.Clip[i].Area) { + for j := range a.Clip[i].Area { + a.Clip[i].Area[j].CTM = b.Clip[i].Area[j].CTM + } + } + } + } + return reflect.DeepEqual(before, after) +} + +// prepareCopiedObject 保真复制原对象,不重新生成字形或丢弃未修改字段 +// 入参: id 新标识, object 对象快照 +// 返回: GraphicObject 独立副本, error 错误信息 +func (e *Editor) prepareCopiedObject(id string, object GraphicObject) (GraphicObject, error) { + origin := e.objectOrigin(editorObjectID(object)) + if origin == nil { + return e.prepareObject(id, object) + } + if !editorXMLTransformable(origin.node) || !editorXMLContainersSupported(origin.node) || !editorXMLCopyable(origin.node) || origin.reason != nil { + return GraphicObject{}, fmt.Errorf("object cannot be copied without changing its original structure") + } + var drawParam string + for parent := origin.node.parent; parent != nil; parent = parent.parent { + if parent.name.Local == "Layer" { + drawParam = parent.attr("DrawParam") + break + } + } + before, err := e.resolveEditorStyle(origin.object, drawParam) + if err != nil { + return GraphicObject{}, err + } + if drawParam != "" && (object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit") { + return GraphicObject{}, fmt.Errorf("copy requires preserving the composite layer draw parameters") + } + if !editorPreservedObject(before, object) { + if !editorXMLSupported(origin.node) { + return GraphicObject{}, fmt.Errorf("copy would replace unsupported object content") + } + return e.prepareObject(id, object) + } + if err := validateEditorGeometry(object); err != nil { + return GraphicObject{}, err + } + if err := e.validatePreservedAppearance(before, object); err != nil { + return GraphicObject{}, err + } + result := cloneEditorData(object) + switch result.Type { + case "TextObject": + result.TextObject.ID = id + case "PathObject": + result.PathObject.ID = id + case "ImageObject": + result.ImageObject.ID = id + case "CompositeObject", "CompositeGraphicUnit": + result.CompositeGraphicUnit.ID = id + } + return result, nil +} + +// validatePreservedAppearance 校验保真对象实际修改的颜色、描边和裁剪,不重新解释未修改字段 +// 入参: before 原对象, after 新对象 +// 返回: error 错误信息 +func (e *Editor) validatePreservedAppearance(before, after GraphicObject) error { + old := []*FillColor{before.TextObject.FillColor, (*FillColor)(before.TextObject.StrokeColor), before.PathObject.FillColor, (*FillColor)(before.PathObject.StrokeColor)} + next := []*FillColor{after.TextObject.FillColor, (*FillColor)(after.TextObject.StrokeColor), after.PathObject.FillColor, (*FillColor)(after.PathObject.StrokeColor)} + for i, value := range next { + if !reflect.DeepEqual(old[i], value) { + if err := e.editorColor(value); err != nil { + return err + } + } + } + a, b := *editorObjectClips(&before), *editorObjectClips(&after) + if b != nil { + count := 0 + if a != nil { + count = len(a.Clip) + } + for i, clip := range b.Clip { + if i >= count { + if err := e.validateObjectClips(&Clips{Clip: []Clip{clip}}); err != nil { + return err + } + continue + } + for j, area := range clip.Area { + if area.CTM != a.Clip[i].Area[j].CTM && area.CTM != "" { + if _, err := creationNumbers(area.CTM, 6); err != nil { + return err + } + } + } + } + } + if after.Type == "PathObject" { + return validateEditorStroke(after.PathObject) + } + return nil +} + // editorContainerOrderable 判断容器的直接对象能否在保留页块的前提下排序 // 入参: container 图层或页块 // 返回: bool 是否可排序 func editorContainerOrderable(container *editorXML) bool { for _, child := range container.children { - if !editorXMLSupported(child) { + if !editorXMLTransformable(child) { return false } } @@ -430,6 +571,8 @@ func editorGeometry(object GraphicObject) (string, string) { return object.PathObject.Boundary, object.PathObject.CTM case "ImageObject": return object.ImageObject.Boundary, object.ImageObject.CTM + case "CompositeObject", "CompositeGraphicUnit": + return object.CompositeGraphicUnit.Boundary, object.CompositeGraphicUnit.CTM } return "", "" } @@ -456,6 +599,46 @@ func (e *Editor) editorFont(id string) (*font.SFNT, error) { return nil, &EditError{Code: EditFontUnavailable, Err: fmt.Errorf("embedded font %q not found", id)} } +// editorTextMeasurable 判断原字形能否在不使用回退字体的情况下度量 +// 入参: text 原文字对象 +// 返回: bool 是否可准确度量 +func (e *Editor) editorTextMeasurable(text TextObject) bool { + sfnt, err := e.editorFont(text.Font) + if err != nil { + return false + } + transforms := make(map[int]CGTransform, len(text.CGTransform)) + for _, transform := range text.CGTransform { + transforms[transform.CodePosition] = transform + } + offset := 0 + for _, code := range text.TextCode { + runes := textCodeRunes(code.Value) + for i := 0; i < len(runes); { + if transform, ok := transforms[offset+i]; ok { + ids := parseInts(transform.Glyphs) + count := max(1, transform.CodeCount) + if len(ids) == 0 || count > len(runes)-i { + return false + } + for _, id := range ids { + if id <= 0 || id >= int(sfnt.NumGlyphs()) { + return false + } + } + i += count + } else { + if sfnt.GlyphIndex(runes[i]) == 0 { + return false + } + i++ + } + } + offset += len(runes) + } + return true +} + // editorImage 按需读取原图片尺寸,不解码完整像素 // 入参: id 图片资源标识 // 返回: image.Point 图片尺寸, error 错误信息 @@ -541,9 +724,23 @@ func validateEditorGeometry(object GraphicObject) error { return err } } + var alpha *int + switch object.Type { + case "TextObject": + alpha = object.TextObject.Alpha + case "PathObject": + alpha = object.PathObject.Alpha + case "ImageObject": + alpha = object.ImageObject.Alpha + case "CompositeObject", "CompositeGraphicUnit": + alpha = object.CompositeGraphicUnit.Alpha + } + if alpha != nil && (*alpha < 0 || *alpha > 255) { + return fmt.Errorf("alpha must be between 0 and 255") + } if object.Type == "TextObject" { obj := object.TextObject - if !finite(obj.Size) || obj.Size <= 0 || !finite(obj.LineWidth) { + if !finite(obj.Size) || obj.Size <= 0 || !finite(obj.LineWidth) || obj.LineWidth < 0 { return fmt.Errorf("invalid text geometry") } for _, code := range obj.TextCode { diff --git a/ofdgo_editor_package.go b/ofdgo_editor_package.go index 94bb05e..6f835a7 100644 --- a/ofdgo_editor_package.go +++ b/ofdgo_editor_package.go @@ -66,17 +66,17 @@ func (e *Editor) sourceParts(progress editorProgress) (map[string][]byte, error) if err := progress.report("pages", len(e.pages), len(e.pages)); err != nil { return nil, err } - fonts, images, resourceFiles := e.usedResources() + fonts, images, spaces, resourceFiles := e.usedResources() commonData := source.document.CommonData for _, name := range append(slices.Clone(commonData.DocumentRes), commonData.PublicRes...) { resourceFiles = slices.DeleteFunc(resourceFiles, func(file string) bool { return strings.EqualFold(file, reader.ResPath(name)) }) } - if len(fonts)+len(images) != 0 { + if len(fonts)+len(images)+len(spaces) != 0 { resourcePath := path.Join(source.directory, "Resources.xml") resourceFiles = append(resourceFiles, resourcePath) - data, err := encodeOFDXML(func(x *ofdXML) { x.resources(fonts, images) }) + data, err := encodeOFDXML(func(x *ofdXML) { x.resources(fonts, images, spaces) }) if err != nil { return nil, err } diff --git a/ofdgo_editor_style.go b/ofdgo_editor_style.go index bf4f29b..9753dfb 100644 --- a/ofdgo_editor_style.go +++ b/ofdgo_editor_style.go @@ -87,7 +87,7 @@ func (e *Editor) resolveEditorStyle(object GraphicObject, layer string) (Graphic obj.FillColor, obj.StrokeColor = style.FillColor, style.StrokeColor } for _, color := range []*FillColor{style.FillColor, (*FillColor)(style.StrokeColor)} { - if err := creationColor(color); err != nil { + if err := e.editorColor(color); err != nil { return GraphicObject{}, &EditError{Code: EditUnsupportedColor, Err: fmt.Errorf("unsupported draw parameter color: %w", err)} } } @@ -103,7 +103,7 @@ type ObjectStyle struct { Join *string } -// StyleObjects 原子更新文字、图片和路径透明度,路径另支持描边样式 +// StyleObjects 原子更新文字、图片、路径与复合对象透明度,路径另支持描边样式 // 入参: page 页面索引, ids 对象标识, style 待修改属性 // 返回: error 错误信息 func (e *Editor) StyleObjects(page int, ids []string, style ObjectStyle) error { @@ -129,6 +129,10 @@ func (e *Editor) StyleObjects(page int, ids []string, style ObjectStyle) error { if style.Alpha != nil { object.ImageObject.Alpha = style.Alpha } + case "CompositeObject", "CompositeGraphicUnit": + if style.Alpha != nil { + object.CompositeGraphicUnit.Alpha = style.Alpha + } case "PathObject": path := &object.PathObject if style.Alpha != nil { @@ -208,7 +212,7 @@ func (e *Editor) CopyStyle(page int, ids []string, source GraphicObject) error { return err } for _, color := range []*FillColor{from.FillColor, (*FillColor)(from.StrokeColor)} { - if err := creationColor(color); err != nil { + if err := e.editorColor(color); err != nil { return err } } diff --git a/ofdgo_editor_text.go b/ofdgo_editor_text.go index 65639cb..95aee4b 100644 --- a/ofdgo_editor_text.go +++ b/ofdgo_editor_text.go @@ -16,6 +16,7 @@ package ofdgo import ( "fmt" + "image/color" "strings" "github.com/go-text/typesetting/segmenter" @@ -125,10 +126,19 @@ func (e *Editor) styleTextObjects(objects []GraphicObject, style TextStyle) ([]G } } if style.Color != "" { - if text.FillColor == nil { - text.FillColor = &FillColor{} + if err := creationColor(&FillColor{Value: style.Color}); err != nil { + return nil, err } - text.FillColor.Value = style.Color + var r, g, b float64 + fmt.Sscan(style.Color, &r, &g, &b) + value, err := e.RGBColor(color.NRGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255}) + if err != nil { + return nil, err + } + if text.FillColor != nil { + value.Alpha = text.FillColor.Alpha + } + text.FillColor = value } } return updates, nil diff --git a/ofdgo_editor_transform.go b/ofdgo_editor_transform.go index 2b61206..2826510 100644 --- a/ofdgo_editor_transform.go +++ b/ofdgo_editor_transform.go @@ -93,7 +93,7 @@ func (e *Editor) ResizeObjects(page int, ids []string, box Box) error { for i, object := range objects { object = cloneEditorData(object) if uniform { - object, err = transformEditorObject(object, matrix.e, matrix.f, sx) + object, err = e.transformObject(object, matrix.e, matrix.f, sx) } else if object.Type == "ImageObject" { object = transformEditorMatrix(object, matrix) } else { @@ -107,6 +107,16 @@ func (e *Editor) ResizeObjects(page int, ids []string, box Box) error { return e.updateObjects(page, objects, true) } +// transformObject 对原有复杂对象仅更新变换,不重建其文字、画刷或其他局部属性 +// 入参: object 对象, dx、dy 页面位移, scale 缩放比例 +// 返回: GraphicObject 新对象, error 错误信息 +func (e *Editor) transformObject(object GraphicObject, dx, dy, scale float64) (GraphicObject, error) { + if origin := e.objectOrigin(editorObjectID(object)); origin != nil && (!editorXMLSupported(origin.node) || origin.reason != nil) { + return transformEditorMatrix(object, Matrix{a: scale, d: scale, e: dx, f: dy}), nil + } + return transformEditorObject(object, dx, dy, scale) +} + // orientObjects 更新Boundary与CTM,不重排文字或重采样图片 // 入参: page 页面索引, ids 对象标识, matrix 直角旋转或镜像矩阵 // 返回: error 错误信息 @@ -143,6 +153,8 @@ func transformEditorMatrix(object GraphicObject, matrix Matrix) GraphicObject { boundary, ctm = &object.PathObject.Boundary, &object.PathObject.CTM case "ImageObject": boundary, ctm = &object.ImageObject.Boundary, &object.ImageObject.CTM + case "CompositeObject", "CompositeGraphicUnit": + boundary, ctm = &object.CompositeGraphicUnit.Boundary, &object.CompositeGraphicUnit.CTM } before, _ := ParseBox(*boundary) if object.Type == "ImageObject" && *ctm == "" { diff --git a/ofdgo_editor_xml.go b/ofdgo_editor_xml.go index e211c82..0470be5 100644 --- a/ofdgo_editor_xml.go +++ b/ofdgo_editor_xml.go @@ -244,7 +244,7 @@ func editorXMLSupported(node *editorXML) bool { case "TextCode": allowed = "X Y DeltaX DeltaY" case "FillColor", "StrokeColor": - allowed = "Value Alpha" + allowed = "Value Alpha ColorSpace Index" case "Clips": allowed, children = "TransFlag", "Clip" case "Clip": @@ -266,6 +266,54 @@ func editorXMLSupported(node *editorXML) bool { return true } +// editorXMLTransformable 判断对象是否支持保留原文的几何操作 +// 入参: node 原始对象节点 +// 返回: bool 是否支持 +func editorXMLTransformable(node *editorXML) bool { + var attrs string + switch node.name.Local { + case "TextObject": + attrs = "ID Boundary CTM DrawParam Font Size HScale VScale Weight ReadDirection CharDirection LineWidth MiterLimit Join Italic Decoration Visible Stroke Fill Alpha" + case "PathObject": + attrs = "ID Boundary CTM DrawParam LineWidth MiterLimit Join Cap Rule DashPattern DashOffset Visible Stroke Fill Alpha" + case "ImageObject": + attrs = "ID Boundary CTM ResourceID ImageMask Visible Alpha" + case "CompositeObject", "CompositeGraphicUnit": + attrs = "ID Boundary CTM ResourceID DrawParam Visible Alpha" + default: + return false + } + return editorXMLAttributes(node, attrs) +} + +// editorXMLContainersSupported 判断父级容器是否仅含可保留的标识和样式 +// 入参: node 对象节点 +// 返回: bool 是否支持容器内操作 +func editorXMLContainersSupported(node *editorXML) bool { + for parent := node.parent; parent.name.Local != "Content"; parent = parent.parent { + allowed := "ID" + if parent.name.Local == "Layer" { + allowed += " Type DrawParam" + } + if !editorXMLAttributes(parent, allowed) { + return false + } + } + return true +} + +// editorXMLCopyable 判断独立复制是否无需重写内部标识或动作引用 +// 入参: node 原始对象节点 +// 返回: bool 是否支持保真复制 +func editorXMLCopyable(node *editorXML) bool { + for _, child := range node.children { + if child.attr("ID") != "" || child.name.Local == "Actions" || !editorXMLCopyable(child) { + return false + } + } + return true +} + // editorXMLObject 修改对象发生变化的属性与内容,保留原文中的显式默认值 // 有效样式快照在修改后展开原绘制参数,避免已清除的属性重新继承 // 入参: data 页面原文, node 原对象节点, before 原对象, after 新对象 @@ -274,7 +322,7 @@ func editorXMLObject(data []byte, node *editorXML, before, after GraphicObject) if before.Type != after.Type { return editorObjectXML(after) } - if node.attr("DrawParam") != "" && before.TextObject.DrawParam == "" && before.PathObject.DrawParam == "" { + if node.attr("DrawParam") != "" && (before.Type == "TextObject" && before.TextObject.DrawParam == "" || before.Type == "PathObject" && before.PathObject.DrawParam == "") { before = GraphicObject{Type: before.Type} var err error switch before.Type { @@ -342,9 +390,6 @@ func editorXMLMerge(data []byte, node *editorXML, oldXML, newXML []byte) ([]byte } } } - if contentChanged && attr.Name.Space == "xmlns" && attr.Name.Local == "ofd" { - continue - } key := attr.Name.Local if attr.Name.Space != "" { key = attr.Name.Space + ":" + key @@ -360,15 +405,12 @@ func editorXMLMerge(data []byte, node *editorXML, oldXML, newXML []byte) ([]byte result.WriteByte('"') } } - if contentChanged { - result.WriteString(" xmlns:ofd=\"" + ofdNamespace + "\"") - } result.WriteByte('>') if contentChanged { matching := len(node.children) > 0 && len(node.children) == len(oldNode.children) && len(node.children) == len(newNode.children) if matching { for i, child := range node.children { - if child.name.Local != oldNode.children[i].name.Local || child.name.Local != newNode.children[i].name.Local { + if child.name.Space != "" && child.name.Space != ofdNamespace && child.name.Space != "http://www.ofdspec.org" || child.name.Local != oldNode.children[i].name.Local || child.name.Local != newNode.children[i].name.Local { matching = false break } @@ -388,7 +430,11 @@ func editorXMLMerge(data []byte, node *editorXML, oldXML, newXML []byte) ([]byte } result.Write(data[position:node.close]) } else { - result.Write(newXML[newNode.open:newNode.close]) + content, err := editorXMLMergeContent(data, node, oldXML, oldNode, newXML, newNode) + if err != nil { + return nil, err + } + result.Write(content) } } else if node.open != node.end { result.Write(data[node.open:node.close]) @@ -397,6 +443,99 @@ func editorXMLMerge(data []byte, node *editorXML, oldXML, newXML []byte) ([]byte return result.Bytes(), nil } +// editorXMLMergeContent 按字段合并子节点,保留未修改字段、扩展节点和注释 +// 入参: data、node 原文节点, oldXML、oldNode 修改前编码, newXML、newNode 修改后编码 +// 返回: []byte 子节点内容, error 错误信息 +func editorXMLMergeContent(data []byte, node *editorXML, oldXML []byte, oldNode *editorXML, newXML []byte, newNode *editorXML) ([]byte, error) { + if len(oldNode.children) == 0 && len(newNode.children) == 0 { + return newXML[newNode.open:newNode.close], nil + } + groups := func(root *editorXML, encoded bool) map[string][]*editorXML { + result := make(map[string][]*editorXML) + for _, child := range root.children { + if encoded || child.name.Space == "" || child.name.Space == ofdNamespace || child.name.Space == "http://www.ofdspec.org" { + result[child.name.Local] = append(result[child.name.Local], child) + } + } + return result + } + original, before, after := groups(node, false), groups(oldNode, true), groups(newNode, true) + var names []string + for _, child := range append(slices.Clone(newNode.children), oldNode.children...) { + if !slices.Contains(names, child.name.Local) { + names = append(names, child.name.Local) + } + } + var patches []editorXMLPatch + for _, name := range names { + old, next, source := before[name], after[name], original[name] + var oldData, newData []byte + for _, child := range old { + oldData = append(oldData, oldXML[child.start:child.end]...) + } + for _, child := range next { + newData = append(newData, newXML[child.start:child.end]...) + } + if bytes.Equal(oldData, newData) { + continue + } + if len(source) > 0 && len(source) == len(old) { + for i, child := range source { + var encoded []byte + if i < len(next) { + var err error + encoded, err = editorXMLMerge(data, child, oldXML[old[i].start:old[i].end], newXML[next[i].start:next[i].end]) + if err != nil { + return nil, err + } + } + patches = append(patches, editorXMLPatch{child.start - node.open, child.end - node.open, encoded}) + } + if len(next) > len(source) { + var added []byte + for _, child := range next[len(source):] { + added = append(added, editorXMLEncodedFragment(newXML, child)...) + } + position := source[len(source)-1].end - node.open + patches = append(patches, editorXMLPatch{position, position, added}) + } + continue + } + newData = nil + for _, child := range next { + newData = append(newData, editorXMLEncodedFragment(newXML, child)...) + } + if len(source) > 0 { + for i, child := range source { + var encoded []byte + if i == 0 { + encoded = newData + } + patches = append(patches, editorXMLPatch{child.start - node.open, child.end - node.open, encoded}) + } + } else if len(next) > 0 { + position := node.close + for _, child := range newNode.children { + if child.start > next[0].start && len(original[child.name.Local]) > 0 { + position = original[child.name.Local][0].start + break + } + } + patches = append(patches, editorXMLPatch{position - node.open, position - node.open, newData}) + } + } + return editorPatchXML(data[node.open:node.close], patches), nil +} + +// editorXMLEncodedFragment 为新增的标准编码节点局部声明前缀,不覆盖原文命名空间 +// 入参: data 标准编码内容, node 待插入的子节点 +// 返回: []byte 独立片段 +func editorXMLEncodedFragment(data []byte, node *editorXML) []byte { + position := node.open - node.start - 1 + fragment := data[node.start:node.end] + return editorPatchXML(fragment, []editorXMLPatch{{position, position, []byte(" xmlns:ofd=\"" + ofdNamespace + "\"")}}) +} + // editorXMLContainer 用标准编码器封装已编码的子节点 // 入参: name 节点名称, attrs 属性, content 子节点XML // 返回: []byte XML片段, error 错误信息 diff --git a/ofdgo_reader.go b/ofdgo_reader.go index fd6c4cb..43034ec 100644 --- a/ofdgo_reader.go +++ b/ofdgo_reader.go @@ -220,6 +220,7 @@ func (r *Reader) loadRes(resPath string) { for i := range res.ColorSpaces.ColorSpace { cs := &res.ColorSpaces.ColorSpace[i] r.colorSpaceCache[cs.ID] = cs + r.resourceFiles[cs.ID] = fullPath } for _, mm := range res.MultiMedias.MultiMedia { if mm.MediaFile != "" { @@ -456,7 +457,7 @@ func (r *Reader) ResPath(resLink string) string { if rootDir != "" && (resLink == rootDir || strings.HasPrefix(resLink, rootDir+"/")) { return resLink } - return path.Join(r.RootDir, resLink) + return cleanPackagePath(path.Join(r.RootDir, resLink)) } // ResData 获取资源文件数据 diff --git a/ofdgo_renderer_bounds.go b/ofdgo_renderer_bounds.go index a82887d..879498d 100644 --- a/ofdgo_renderer_bounds.go +++ b/ofdgo_renderer_bounds.go @@ -21,7 +21,7 @@ import ( "github.com/tdewolff/canvas" ) -// ObjectBounds 获取文字、路径或图片在页面坐标中的轴对齐范围,不修改对象 +// ObjectBounds 获取文字、路径、图片或复合对象在页面坐标中的轴对齐范围,不修改对象 // 文字采用字形范围,路径包含描边与裁剪,图片采用裁剪后的几何范围,不解码像素或排除透明像素 // 底纹按填充或描边轮廓度量,不展开图案单元 // 不应用页面边界或父级变换;无可见范围时返回零值,不支持的对象类型返回错误 @@ -37,13 +37,13 @@ type ObjectContour struct { EvenOdd bool `json:"evenOdd,omitempty"` } -// ObjectContours 获取路径或图片的实际绘制区域,包含描边、虚线、变换及对象裁剪 +// ObjectContours 获取路径、图片或复合对象的实际绘制区域,包含描边、虚线、变换及对象裁剪 // 图片不解码像素,底纹不展开图案单元;不包含页面边界或父级变换 -// 入参: object 路径或图片对象, drawParam 图层绘制参数标识 +// 入参: object 路径、图片或复合对象, drawParam 图层绘制参数标识 // 返回: []ObjectContour 可用于点选的轮廓, error 错误信息 func (r *Renderer) ObjectContours(object GraphicObject, drawParam string) ([]ObjectContour, error) { - if object.Type != "PathObject" && object.Type != "ImageObject" { - return nil, fmt.Errorf("contours require a path or image object") + if object.Type != "PathObject" && object.Type != "ImageObject" && object.Type != "CompositeObject" && object.Type != "CompositeGraphicUnit" { + return nil, fmt.Errorf("contours require a path, image or composite object") } bounds := &boundsRenderer{collect: true} _, err := r.measureObject(object, drawParam, bounds) @@ -54,7 +54,7 @@ func (r *Renderer) ObjectContours(object GraphicObject, drawParam string) ([]Obj // 入参: object 对象, drawParam 图层绘制参数标识, bounds 度量目标 // 返回: Box 毫米范围, error 错误信息 func (r *Renderer) measureObject(object GraphicObject, drawParam string, bounds *boundsRenderer) (Box, error) { - if object.Type != "TextObject" && object.Type != "PathObject" && object.Type != "ImageObject" { + if object.Type != "TextObject" && object.Type != "PathObject" && object.Type != "ImageObject" && object.Type != "CompositeObject" && object.Type != "CompositeGraphicUnit" { return Box{}, fmt.Errorf("unsupported object type %q", object.Type) } boundary, ctm := editorGeometry(object) @@ -97,28 +97,43 @@ func (r *Renderer) measureObject(object GraphicObject, drawParam string, bounds obj.StrokeColor = (*StrokeColor)(boundsColor((*FillColor)(obj.StrokeColor))) renderer.renderPath(ctx, obj, 0, defaults, nil, false, nil) case "ImageObject": - obj := object.ImageObject - if obj.Visible != nil && !*obj.Visible || obj.Alpha != nil && *obj.Alpha == 0 { - return Box{}, nil - } - box, _ := ParseBox(obj.Boundary) - ctm := NewMatrix(obj.CTM) - if obj.CTM == "" { - ctm = Matrix{a: box.W, d: box.H} - } - p := canvas.Rectangle(1, 1).Transform(canvas.Matrix{{ctm.a, ctm.c, box.X + ctm.e}, {-ctm.b, -ctm.d, -box.Y - ctm.f}}) - clip := renderer.buildObjectClipPath(obj.Clips, 0, box.X, box.Y, ctm, nil, false) - bounds.add(applyClipPath(p, clip)) - if obj.Border != nil { - border := *obj.Border - border.BorderColor = (*StrokeColor)(boundsColor((*FillColor)(border.BorderColor))) - obj.Border = &border - renderer.renderImageBorder(ctx, obj, box, 0, nil, false, clip) - } + renderer.measureImage(ctx, object.ImageObject, 0, nil, false, nil) + case "CompositeObject", "CompositeGraphicUnit": + renderer.renderCompositeGraphicUnit(ctx, object.CompositeGraphicUnit, 0, defaults, nil, false, nil) } return bounds.box, nil } +// measureImage 复用图片变换与裁剪语义度量范围,不读取像素数据 +// 入参: ctx 度量画布, obj 图片, pageH 页面高度, parentCTM 父变换, boundaryInCTM 边界是否参与变换, parentClip 父裁剪 +func (r *Renderer) measureImage(ctx *canvas.Context, obj ImageObject, pageH float64, parentCTM *Matrix, boundaryInCTM bool, parentClip *canvas.Path) { + if obj.Visible != nil && !*obj.Visible || obj.Alpha != nil && *obj.Alpha == 0 { + return + } + box, _ := ParseBox(obj.Boundary) + ctm := NewMatrix(obj.CTM) + if obj.CTM == "" { + ctm = Matrix{a: box.W, d: box.H} + } + m := TranslationMatrix(box.X, box.Y).Multiply(ctm) + if parentCTM != nil { + if boundaryInCTM { + m = parentCTM.Multiply(m) + } else { + m = TranslationMatrix(box.X, box.Y).Multiply(*parentCTM).Multiply(ctm) + } + } + p := canvas.Rectangle(1, 1).Transform(canvas.Matrix{{m.a, m.c, m.e}, {-m.b, -m.d, pageH - m.f}}) + clip := intersectClipPath(parentClip, r.buildObjectClipPath(obj.Clips, pageH, box.X, box.Y, ctm, parentCTM, boundaryInCTM)) + ctx.Renderer.(*boundsRenderer).add(applyClipPath(p, clip)) + if obj.Border != nil { + border := *obj.Border + border.BorderColor = (*StrokeColor)(boundsColor((*FillColor)(border.BorderColor))) + obj.Border = &border + r.renderImageBorder(ctx, obj, box, pageH, parentCTM, boundaryInCTM, clip) + } +} + // boundsColor 以底纹的基础颜色和透明度度量轮廓,不修改原画刷 // 入参: color 填充或描边颜色 // 返回: *FillColor 用于度量的颜色 diff --git a/ofdgo_renderer_image.go b/ofdgo_renderer_image.go index a0845fb..de85c27 100644 --- a/ofdgo_renderer_image.go +++ b/ofdgo_renderer_image.go @@ -34,6 +34,10 @@ func (r *Renderer) renderImage(ctx *canvas.Context, obj ImageObject, pageH float if r.textOnly { return } + if _, ok := ctx.Renderer.(*boundsRenderer); ok { + r.measureImage(ctx, obj, pageH, parentCTM, boundaryInCTM, parentClip) + return + } if obj.Visible != nil && !*obj.Visible { return } diff --git a/ofdgo_renderer_path.go b/ofdgo_renderer_path.go index 067ee11..1349fc5 100644 --- a/ofdgo_renderer_path.go +++ b/ofdgo_renderer_path.go @@ -304,6 +304,12 @@ func (r *Renderer) renderPath(ctx *canvas.Context, obj PathObject, pageH float64 // renderPattern 渲染图案填充 // 入参: ctx 画布上下文, pattern 底纹画刷, pageH 页面高度, clip 绘制区域, objectCTM 对象变换矩阵 func (r *Renderer) renderPattern(ctx *canvas.Context, pattern *patternPaint, pageH float64, clip *canvas.Path, objectCTM Matrix) { + if bounds, ok := ctx.Renderer.(*boundsRenderer); ok { + if pattern.alpha == nil || *pattern.alpha > 0 { + bounds.add(clip) + } + return + } if r.pageText != nil { renderer := *r renderer.pageText = nil diff --git a/ofdgo_writer.go b/ofdgo_writer.go index 0c7d0c7..4473667 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -22,6 +22,7 @@ import ( "fmt" "io" "maps" + "reflect" "slices" "strconv" "time" @@ -201,7 +202,7 @@ func (e *Editor) validate() error { // 入参: write 条目写入方法, progress 保存进度回调,预览时为nil // 返回: error 错误信息 func (e *Editor) writeParts(write func(string, []byte, bool) error, progress editorProgress) error { - fonts, images, _ := e.usedResources() + fonts, images, spaces, _ := e.usedResources() writeXML := func(name string, encode func(*ofdXML)) error { data, err := encodeOFDXML(encode) if err != nil { @@ -253,7 +254,7 @@ func (e *Editor) writeParts(write func(string, []byte, bool) error, progress edi x.start("PageArea", nil) x.text("PhysicalBox", e.pages[0].Area.PhysicalBox) x.end("PageArea") - if len(fonts)+len(images) != 0 { + if len(fonts)+len(images)+len(spaces) != 0 { x.text("DocumentRes", "DocumentRes.xml") } x.end("CommonData") @@ -270,8 +271,8 @@ func (e *Editor) writeParts(write func(string, []byte, bool) error, progress edi }); err != nil { return err } - if len(fonts)+len(images) != 0 { - if err := writeXML("Doc_0/DocumentRes.xml", func(x *ofdXML) { x.resources(fonts, images) }); err != nil { + if len(fonts)+len(images)+len(spaces) != 0 { + if err := writeXML("Doc_0/DocumentRes.xml", func(x *ofdXML) { x.resources(fonts, images, spaces) }); err != nil { return err } } @@ -299,10 +300,11 @@ func (e *Editor) writeParts(write func(string, []byte, bool) error, progress edi } // usedResources 筛选当前引用的新增资源及需要跨页复用的原资源文件,不修改资源池 -// 返回: []editorResource 新增字体, []editorResource 新增图片, []string 原资源文件 -func (e *Editor) usedResources() (fonts, images []editorResource, sourceFiles []string) { +// 返回: []editorResource 新增字体, []editorResource 新增图片, []ColorSpace 新增颜色空间, []string 原资源文件 +func (e *Editor) usedResources() (fonts, images []editorResource, spaces []ColorSpace, sourceFiles []string) { used := make(map[string]bool) promoted := make(map[string]bool) + files := make(map[string]bool) for _, page := range e.pages { var original map[string]GraphicObject if e.source != nil { @@ -318,6 +320,19 @@ func (e *Editor) usedResources() (fonts, images []editorResource, sourceFiles [] for _, layer := range page.Content.Layer { for _, object := range layer.Objects { before, exists := original[editorObjectID(object)] + if origin := e.objectOrigin(editorObjectID(object)); origin != nil && !exists { + 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 "TextObject": used[object.TextObject.Font] = true @@ -348,14 +363,17 @@ func (e *Editor) usedResources() (fonts, images []editorResource, sourceFiles [] images = append(images, resource) delete(promoted, resource.image.ID) } + if resource.space != nil && used[resource.space.ID] { + spaces = append(spaces, *resource.space) + delete(promoted, resource.space.ID) + } } - files := make(map[string]bool) for id := range promoted { if name := e.source.reader.resourceFiles[id]; name != "" { files[name] = true } } - return fonts, images, slices.Sorted(maps.Keys(files)) + return fonts, images, spaces, slices.Sorted(maps.Keys(files)) } // page 写出页面尺寸、图层和对象 @@ -387,9 +405,17 @@ func (x *ofdXML) layer(layer Layer) { } // resources 写出文档资源索引 -// 入参: fonts 字体资源, images 图片资源 -func (x *ofdXML) resources(fonts, images []editorResource) { +// 入参: fonts 字体资源, images 图片资源, spaces 颜色空间 +func (x *ofdXML) resources(fonts, images []editorResource, spaces []ColorSpace) { x.root("Res", ofdAttrs{{Name: xml.Name{Local: "BaseLoc"}, Value: "Res"}}) + if len(spaces) != 0 { + x.start("ColorSpaces", nil) + for _, space := range spaces { + x.start("ColorSpace", ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: space.ID}, {Name: xml.Name{Local: "Type"}, Value: space.Type}, {Name: xml.Name{Local: "BitsPerComponent"}, Value: strconv.Itoa(space.BitsPerComponent)}}) + x.end("ColorSpace") + } + x.end("ColorSpaces") + } if len(fonts) != 0 { x.start("Fonts", nil) for _, resource := range fonts { @@ -616,10 +642,21 @@ func (x *ofdXML) object(object GraphicObject, root bool) { attrs.add("ImageMask", obj.ImageMask) attrs.flag("Visible", obj.Visible) attrs.alpha(obj.Alpha) + case "CompositeObject", "CompositeGraphicUnit": + obj := object.CompositeGraphicUnit + attrs.add("ID", obj.ID) + attrs.add("Boundary", obj.Boundary) + attrs.add("CTM", obj.CTM) + attrs.add("ResourceID", obj.ResourceID) + attrs.add("DrawParam", obj.DrawParam) + attrs.flag("Visible", obj.Visible) + attrs.alpha(obj.Alpha) } x.start(object.Type, attrs) if object.Type == "ImageObject" { x.clips(object.ImageObject.Clips) + } else if object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" { + x.clips(object.CompositeGraphicUnit.Clips) } else if object.Type == "PathObject" || object.Type == "Path" { x.clips(object.PathObject.Clips) x.color("StrokeColor", stroke) @@ -655,6 +692,7 @@ func (x *ofdXML) clips(clips *Clips) { for _, area := range clip.Area { var attrs ofdAttrs attrs.add("CTM", area.CTM) + attrs.add("DrawParam", area.DrawParam) x.start("Area", attrs) for _, path := range area.Path { x.object(GraphicObject{Type: "Path", PathObject: path}, false) @@ -690,6 +728,10 @@ func (x *ofdXML) color(name string, color *FillColor) { } var attrs ofdAttrs attrs.add("Value", color.Value) + attrs.add("ColorSpace", color.ColorSpace) + if color.Index != nil { + attrs.add("Index", strconv.Itoa(*color.Index)) + } attrs.alpha(color.Alpha) x.start(name, attrs) x.end(name)