diff --git a/assets/webui/index.html b/assets/webui/index.html index 0b899df..0a7067e 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -91,6 +91,11 @@ 对象 +
+ + + 擦除 +
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index f2333fd..6fa0961 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -119,7 +119,8 @@ a:focus-visible { grid-template-columns: repeat(2, max-content); } -.editor-transform { +.editor-transform, +.editor-erase { grid-template-columns: max-content; } @@ -1400,6 +1401,7 @@ button.outline-link:hover { .edit-object:hover, .edit-object.selected, +.edit-object.erasing, .edit-selection, .edit-crop { outline: calc(1px / var(--surface-scale)) solid var(--accent); @@ -1419,6 +1421,11 @@ button.outline-link:hover { background: rgba(15, 123, 104, 0.04); } +.edit-object.erasing { + background: var(--accent-soft); + outline-style: dashed; +} + .edit-handle { display: none; position: absolute; diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 7a54511..9bc9110 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -52,6 +52,7 @@ const state = { searchIndex: -1, navigationScroll: new Map(), fontSyncPending: false, + fontCatalogLoading: false, fontRenderPending: false, fontSyncing: false, doc: null, @@ -117,6 +118,8 @@ const el = { drawLineButton: document.querySelector("#drawLineButton"), drawRectangleButton: document.querySelector("#drawRectangleButton"), drawEllipseButton: document.querySelector("#drawEllipseButton"), + eraseObjectButton: document.querySelector("#eraseObjectButton"), + eraseRegionButton: document.querySelector("#eraseRegionButton"), shapeFill: document.querySelector("#shapeFill"), shapeFillColor: document.querySelector("#shapeFillColor"), shapeStroke: document.querySelector("#shapeStroke"), @@ -246,7 +249,7 @@ const fontManager = new FontManager({ onChange: scheduleFontSync, onPermissionChange: updateFontPermissionHint, }); -const fontPicker = new FontPicker(el.textFont, el.textFontToggle, el.textFontList, changeTextFont); +const fontPicker = new FontPicker(el.textFont, el.textFontToggle, el.textFontList, changeTextFont, loadEditorFonts); el.editorTools.addEventListener("scroll", () => fontPicker.position()); const canvasEditor = new CanvasEditor(el.viewerPanel, { @@ -274,6 +277,13 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, { if (state.fontRenderPending) window.setTimeout(refreshPendingFonts, 0); }, onDelete: (item) => changeDocument(item.items ? "ofdgoDeleteObjects" : "ofdgoDeleteObject", item), + onErase: (items, box) => { + const blocked = items.find(item => !canEditObject(item, box ? "arrange" : "delete")); + if (blocked) { setStatus(objectEditReason(blocked) || "对象暂不可擦除"); return false; } + const item = { index: items[0].index, id: items.map(item => item.id) }; + return box ? changeDocument("ofdgoEraseObjects", item, box.x, box.y, box.width, box.height) + : changeDocument("ofdgoDeleteObjects", item); + }, onEdit: editCanvasObject, drawStyle: shapeStyle, onTool: updateDrawingControls, @@ -426,7 +436,8 @@ el.resetCropButton.addEventListener("click", () => { }); editorClick(el.undoButton, () => changeDocument("ofdgoUndo")); editorClick(el.redoButton, () => changeDocument("ofdgoRedo")); -for (const [button, tool] of [[el.drawLineButton, "line"], [el.drawRectangleButton, "rectangle"], [el.drawEllipseButton, "ellipse"]]) { +for (const [button, tool] of [[el.drawLineButton, "line"], [el.drawRectangleButton, "rectangle"], [el.drawEllipseButton, "ellipse"], + [el.eraseObjectButton, "erase-object"], [el.eraseRegionButton, "erase-region"]]) { editorClick(button, () => { if (!state.editing || document.body.hasAttribute("aria-busy")) { return; @@ -987,6 +998,23 @@ function editorFonts(item) { return fonts; } +async function loadEditorFonts() { + if (!fontManager.canReadLocal() || fontManager.catalogLoaded || fontManager.permission === "denied" || state.fontCatalogLoading) return; + state.fontCatalogLoading = true; + try { + await fontManager.queryLocal(); + const open = fontPicker.open; + const selected = state.textFonts[Number(fontPicker.value)]; + const query = el.textFont.value === (selected?.fullName || selected?.name || "") ? "" : el.textFont.value; + updateTextFonts(canvasEditor.selected, true); + if (open && !el.textFont.disabled) { if (query) el.textFont.value = query; fontPicker.show(query); } + } catch (err) { + setStatus(err?.name === "NotAllowedError" ? "字体尚未授权" : String(err.message || err)); + } finally { + state.fontCatalogLoading = false; + } +} + function updateTextFonts(item, refresh = false) { const id = item?.type === "TextObject" && !item.draft ? item.font : null; if (refresh || state.textFontID !== id) { @@ -1193,6 +1221,10 @@ function updateDrawingControls() { button.disabled = disabled || !pageCan("insert"); button.setAttribute("aria-pressed", String(tool === name)); } + for (const [button, name] of [[el.eraseObjectButton, "erase-object"], [el.eraseRegionButton, "erase-region"]]) { + button.disabled = disabled; + button.setAttribute("aria-pressed", String(tool === name)); + } el.selectObjectButton.setAttribute("aria-pressed", String(canvasEditor.enabled && !tool)); const styleDisabled = disabled || (canvasEditor.selected?.type === "PathObject" ? !canEditObject(canvasEditor.selected, "update") : !pageCan("insert")); @@ -1224,7 +1256,7 @@ async function changeDocument(name, item, ...args) { return true; } setEditorInfo(doc); - if (!item || name === "ofdgoDeleteObject" || name === "ofdgoDeleteObjects") { + if (!item || name === "ofdgoDeleteObject" || name === "ofdgoDeleteObjects" || name === "ofdgoEraseObjects") { canvasEditor.clear(); } if (name === "ofdgoCopyObjects" || name === "ofdgoPasteObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") { diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index 93edf3d..0ba3e9c 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -358,7 +358,8 @@ export class CanvasEditor { return; } if (this.tool) { - this.startShape(event); + if (this.tool.startsWith("erase-")) this.startErase(event); + else this.startShape(event); return; } if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.target.dataset.corner) { @@ -425,6 +426,10 @@ export class CanvasEditor { } event.preventDefault(); drag.pointer = event; + if (drag.erase) { + this.moveErase(event, drag); + return; + } if (drag.crop) { const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.page, drag.rotation); const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.page, drag.rotation); @@ -495,6 +500,14 @@ export class CanvasEditor { return; } this.move(event); + if (drag.erase) { + const items = drag.erase === "erase-object" ? [...drag.erased] : drag.box + ? drag.items.filter(item => item.x < drag.box.x + drag.box.width && item.x + item.width > drag.box.x + && item.y < drag.box.y + drag.box.height && item.y + item.height > drag.box.y) : []; + this.cancel(); + if (items.length) this.options.onErase(items, drag.erase === "erase-region" ? drag.box : null); + return; + } if (drag.crop) { this.drag = null; this.viewer.releasePointerCapture(drag.pointerID); @@ -550,6 +563,7 @@ export class CanvasEditor { const drag = this.drag; this.drag = null; if (drag) { + for (const item of drag.erased || []) item.node.classList.remove("erasing"); if (drag.crop) { this.crop.box = drag.box; this.paintCrop(); @@ -566,6 +580,29 @@ export class CanvasEditor { } } + startErase(event) { + const surface = event.target.closest(".page-surface") || this.nearestSurface(event); + const page = this.pages.get(surface); + if (!page) return; + event.preventDefault(); + this.viewer.focus({ preventScroll: true }); + this.drag = { erase: this.tool, page, surface, items: [...surface.querySelectorAll(".edit-object")].map(node => this.nodes.get(node)), + erased: new Set(), shape: "rectangle", pointerID: event.pointerId, clientX: event.clientX, clientY: event.clientY, + rect: surface.getBoundingClientRect(), rotation: this.options.rotation() }; + if (this.tool === "erase-region") Object.assign(this.drag, this.createPreview(surface, page, "rectangle", + { fill: true, fillColor: "var(--accent-soft)", stroke: true, strokeColor: "var(--accent)", lineWidth: 0.2 })); + this.viewer.setPointerCapture(event.pointerId); + this.moveErase(event, this.drag); + } + + moveErase(event, drag) { + if (drag.erase === "erase-region") { this.moveShape(event, drag); return; } + const point = pagePoint(event.clientX, event.clientY, drag.rect, drag.page, drag.rotation); + const item = [...drag.items].reverse().find(item => point.x >= item.x && point.x <= item.x + item.width + && point.y >= item.y && point.y <= item.y + item.height); + if (item) { drag.erased.add(item); item.node.classList.add("erasing"); } + } + startMarquee(event, additive) { const surface = event.target.closest(".page-surface"), page = this.pages.get(surface); if (!page) { diff --git a/assets/webui/ofdgo_font.js b/assets/webui/ofdgo_font.js index b0cf58d..a69fbad 100644 --- a/assets/webui/ofdgo_font.js +++ b/assets/webui/ofdgo_font.js @@ -1,8 +1,8 @@ const DATABASE = "ofdgo"; export class FontPicker { - constructor(input, toggle, list, onChange) { - Object.assign(this, { input, toggle, list, onChange, fonts: [], value: "", active: -1, open: false }); + constructor(input, toggle, list, onChange, onOpen) { + Object.assign(this, { input, toggle, list, onChange, onOpen, fonts: [], value: "", active: -1, open: false }); input.addEventListener("input", () => this.show(input.value)); input.addEventListener("focus", () => input.select()); input.addEventListener("click", () => { if (!this.open) this.show(); }); @@ -73,6 +73,7 @@ export class FontPicker { this.position(); const selected = this.matches.findIndex(({ font, index }) => !font.disabled && String(index) === this.value); this.activate(selected < 0 ? this.matches.findIndex(({ font }) => !font.disabled) : selected); + this.onOpen?.(); } position() { diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index e2233a3..7d6873a 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -114,6 +114,7 @@ func RunWASM() { registerCallback("ofdgoTransformObjects", transformObjects) registerCallback("ofdgoAlignObjects", alignObjects) registerCallback("ofdgoDeleteObjects", deleteObjects) + registerCallback("ofdgoEraseObjects", eraseObjects) registerCallback("ofdgoCopyObjects", copyObjects) registerCallback("ofdgoCaptureObjects", captureObjects) registerCallback("ofdgoPasteObjects", pasteObjects) @@ -1399,6 +1400,17 @@ func deleteObjects(args []js.Value) (any, error) { }) } +// eraseObjects 按页面矩形范围擦除对象,保留局部裁剪之外的内容。 +// 入参: args 页面索引、对象标识数组和毫米坐标范围 +// 返回: any 文档信息, error 错误信息 +func eraseObjects(args []js.Value) (any, error) { + return changeObjects(func() error { + return currentEditor.EraseObjects(args[0].Int(), stringsFromJS(args[1]), ofdgo.Box{ + X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float(), + }) + }) +} + // changeObjects 提交库层批量操作,无修改时复用当前预览。 // 入参: apply 待执行的编辑操作 // 返回: any 文档信息, error 错误信息 diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 314f77a..19bc9ed 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -30,6 +30,7 @@ import ( "unicode/utf8" "github.com/tdewolff/font" + "github.com/xiaoqidun/jbig2" ) // Editor 编辑OFD文档,长度单位为毫米,页面索引从0开始,实例需串行使用 @@ -346,7 +347,8 @@ func editorFontName(sfnt *font.SFNT, names ...font.NameID) string { return "" } -// AddImage 注册PNG或JPEG图片,保留原始编码,重复资源复用标识,引用后写入文档 +// AddImage 注册PNG或JPEG图片,重复资源复用标识,引用后写入文档 +// 不透明纯黑白PNG仅在无损JBIG2编码更小时转换,其他图片保留原始编码。 // 入参: data 图片数据 // 返回: string 图片资源标识, error 错误信息 func (e *Editor) AddImage(data []byte) (string, error) { @@ -364,10 +366,17 @@ func (e *Editor) AddImage(data []byte) (string, error) { if err := e.prepareSourceIDs(); err != nil { return "", err } + if format == "png" && uint64(config.Width)*uint64(config.Height) <= 64<<20 { + if compact := editorBinaryImage(data); compact != nil { + data, format = compact, "jbig2" + } + } id := e.nextID() extension := format if extension == "jpeg" { extension = "jpg" + } else if extension == "jbig2" { + extension = "jb2" } name := "Image_" + id + "." + extension e.resources = append(e.resources, editorResource{ @@ -380,8 +389,23 @@ func (e *Editor) AddImage(data []byte) (string, error) { return id, nil } +// editorBinaryImage 尝试纯黑白无损编码,不二值化,不改变透明度,无体积收益时保留原图。 +// 入参: data PNG图片数据 +// 返回: []byte 更小的JBIG2数据,不适用时为nil +func editorBinaryImage(data []byte) []byte { + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil + } + var output bytes.Buffer + if err := jbig2.Encode(&output, img, &jbig2.Options{MaxPageBytes: uint64(len(data))}); err != nil || output.Len() >= len(data) { + return nil + } + return output.Bytes() +} + // AddObject 按添加顺序放入正文图层,支持文字、路径和图片,自动分配对象ID -// 支持基本颜色、直接资源引用及图片的路径裁剪,不支持动作、渐变及复合图元 +// 支持基本颜色、直接资源引用及路径裁剪,不支持动作、渐变及复合图元 // 入参: page 页面索引, object 对象内容,添加后不再引用调用方的可变数据 // 返回: string 对象标识, error 错误信息 func (e *Editor) AddObject(page int, object GraphicObject) (string, error) { @@ -579,10 +603,10 @@ 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 || clips != nil && object.Type != "ImageObject" { - return GraphicObject{}, fmt.Errorf("draw parameter references, non-image clips and actions are not supported for creation") + if drawParam != "" || len(actions) != 0 { + return GraphicObject{}, fmt.Errorf("draw parameter references and actions are not supported for creation") } - if err := e.validateImageClips(clips); err != nil { + if err := e.validateObjectClips(clips); err != nil { return GraphicObject{}, err } if alpha != nil && (*alpha < 0 || *alpha > 255) { @@ -599,23 +623,23 @@ func (e *Editor) prepareObject(id string, object GraphicObject) (GraphicObject, return cloneEditorObject(object) } -// validateImageClips 校验图片的路径裁剪,仅接受无资源引用及嵌套裁剪的路径 -// 入参: clips 图片裁剪集合,nil表示无裁剪 +// validateObjectClips 校验对象的路径裁剪,仅接受无资源引用及嵌套裁剪的路径 +// 入参: clips 裁剪集合,nil表示无裁剪 // 返回: error 错误信息 -func (e *Editor) validateImageClips(clips *Clips) error { +func (e *Editor) validateObjectClips(clips *Clips) error { if clips == nil { return nil } if len(clips.Clip) == 0 { - return fmt.Errorf("image clips must contain a clip") + return fmt.Errorf("clips must contain a clip") } for _, clip := range clips.Clip { if len(clip.Area) == 0 { - return fmt.Errorf("image clip must contain an area") + return fmt.Errorf("clip must contain an area") } for _, area := range clip.Area { if len(area.Path) == 0 || area.DrawParam != "" || len(area.Text) != 0 { - return fmt.Errorf("image clips only support paths without draw parameter references") + return fmt.Errorf("clips only support paths without draw parameter references") } if area.CTM != "" { if _, err := creationNumbers(area.CTM, 6); err != nil { @@ -623,8 +647,8 @@ func (e *Editor) validateImageClips(clips *Clips) error { } } for _, path := range area.Path { - if path.ID != "" { - return fmt.Errorf("clip paths must not have object IDs") + if path.ID != "" || path.Clips != nil { + return fmt.Errorf("clip paths must not have object IDs or nested clips") } if _, err := e.prepareObject("", GraphicObject{Type: "PathObject", PathObject: path}); err != nil { return err @@ -726,12 +750,12 @@ func transformEditorObject(object GraphicObject, dx, dy, scale float64) (Graphic boundary, ctm = &object.PathObject.Boundary, &object.PathObject.CTM case "ImageObject": boundary, ctm = &object.ImageObject.Boundary, &object.ImageObject.CTM - if scale != 1 { - object.ImageObject.Clips = transformImageClips(object.ImageObject.Clips, Matrix{a: scale, d: scale}) - } default: return GraphicObject{}, fmt.Errorf("unsupported object type %q", object.Type) } + if clips := editorObjectClips(&object); scale != 1 && *clips != nil && (object.Type == "TextObject" || (*clips).TransFlag != nil && !*(*clips).TransFlag) { + *clips = transformObjectClips(*clips, Matrix{a: scale, d: scale}) + } box, err := ParseBox(*boundary) if err != nil { return GraphicObject{}, err diff --git a/ofdgo_editor_erase.go b/ofdgo_editor_erase.go new file mode 100644 index 0000000..a5dfa3d --- /dev/null +++ b/ofdgo_editor_erase.go @@ -0,0 +1,136 @@ +// 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" + "math" + "reflect" + "slices" +) + +// EraseObjects 擦除同页对象与矩形区域重叠的可见部分,一次撤销恢复全部。 +// 全部覆盖时删除对象;局部覆盖使用OFD路径裁剪,保留原文字和资源,不用于敏感信息脱敏。 +// 不改变未相交对象,无法保真编辑时返回错误且不提交修改。 +// 入参: page 页面索引, ids 对象标识, box 页面毫米坐标中的擦除范围 +// 返回: error 错误信息 +func (e *Editor) EraseObjects(page int, ids []string, box Box) error { + if _, err := creationBox(editorBoxString(box)); err != nil { + return err + } + objects, indexes, err := e.selectedObjects(page, ids) + if err != nil || len(objects) == 0 { + return err + } + boxes, err := e.objectBounds(page, objects) + if err != nil { + return err + } + layers := copyEditorPage(e.pages[page]).Content.Layer + removed := make(map[string]bool) + changed := false + for i, object := range objects { + bounds := boxes[i] + x, y := math.Max(box.X, bounds.X), math.Max(box.Y, bounds.Y) + right, bottom := math.Min(box.X+box.W, bounds.X+bounds.W), math.Min(box.Y+box.H, bounds.Y+bounds.H) + if right <= x || bottom <= y { + continue + } + if x == bounds.X && y == bounds.Y && right == bounds.X+bounds.W && bottom == bounds.Y+bounds.H { + removed[ids[i]] = true + changed = true + continue + } + object, err := cloneEditorObject(object) + if err != nil { + return err + } + boundary, ctm := editorGeometry(object) + frame, _ := ParseBox(boundary) + outer := unionTextBox(bounds, frame) + outer.X, outer.Y, outer.W, outer.H = outer.X-1, outer.Y-1, outer.W+2, outer.H+2 + x, y = math.Max(box.X, outer.X), math.Max(box.Y, outer.Y) + right, bottom = math.Min(box.X+box.W, outer.X+outer.W), math.Min(box.Y+box.H, outer.Y+outer.H) + clips := editorObjectClips(&object) + if *clips == nil { + *clips = &Clips{} + } + matrix := IdentityMatrix + if (*clips).TransFlag == nil || *(*clips).TransFlag { + if object.Type == "ImageObject" && ctm == "" { + ctm = Matrix{a: frame.W, d: frame.H}.String() + } + var ok bool + matrix, ok = NewMatrix(ctm).Invert() + if !ok { + return fmt.Errorf("object %q transform is not invertible", ids[i]) + } + } + clip := Clip{} + for _, area := range []Box{ + {X: outer.X, Y: outer.Y, W: outer.W, H: y - outer.Y}, + {X: outer.X, Y: bottom, W: outer.W, H: outer.Y + outer.H - bottom}, + {X: outer.X, Y: y, W: x - outer.X, H: bottom - y}, + {X: right, Y: y, W: outer.X + outer.W - right, H: bottom - y}, + } { + if area.W <= 0 || area.H <= 0 { + continue + } + area.X, area.Y = area.X-frame.X, area.Y-frame.Y + path, err := NewShape(ShapeRectangle, area) + if err != nil { + return err + } + fill, stroke := true, false + path.Fill, path.Stroke = &fill, &stroke + clip.Area = append(clip.Area, ClipArea{CTM: matrix.String(), Path: []PathObject{path}}) + } + if slices.ContainsFunc((*clips).Clip, func(previous Clip) bool { return reflect.DeepEqual(previous, clip) }) { + continue + } + (*clips).Clip = append((*clips).Clip, clip) + changed = true + position := indexes[i] + layers[position.layer].Objects[position.index] = object + } + if changed { + for i := range layers { + objects := layers[i].Objects[:0] + for _, object := range layers[i].Objects { + if !removed[editorObjectID(object)] { + objects = append(objects, object) + } + } + layers[i].Objects = objects + } + e.replaceLayers(page, layers) + } + return nil +} + +// editorObjectClips 获取基本对象的裁剪字段。 +// 入参: object 文字、路径或图片对象 +// 返回: **Clips 裁剪字段地址,不支持的对象返回nil +func editorObjectClips(object *GraphicObject) **Clips { + switch object.Type { + case "TextObject": + return &object.TextObject.Clips + case "PathObject": + return &object.PathObject.Clips + case "ImageObject": + return &object.ImageObject.Clips + } + return nil +} diff --git a/ofdgo_editor_package.go b/ofdgo_editor_package.go index 761117e..d64fe7d 100644 --- a/ofdgo_editor_package.go +++ b/ofdgo_editor_package.go @@ -382,12 +382,21 @@ func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte) (int64, return 0, err } maps.Copy(parts, fonts) + removed, err := e.pruneSourceResources(parts) + if err != nil { + return 0, err + } reader := e.source.reader remaining := maps.Clone(reader.files) if remaining == nil { remaining = make(map[string][]byte) } maps.Copy(remaining, parts) + for name := range remaining { + if removed[strings.ToLower(cleanPackagePath(name))] { + delete(remaining, name) + } + } output := &ofdCountingWriter{writer: writer} archive := zip.NewWriter(output) if reader.Zip != nil { @@ -401,6 +410,9 @@ func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte) (int64, continue } name := cleanPackagePath(file.Name) + if removed[strings.ToLower(name)] { + continue + } if data, ok := remaining[name]; ok { header := file.FileHeader entry, err := archive.CreateHeader(&header) diff --git a/ofdgo_editor_resources.go b/ofdgo_editor_resources.go new file mode 100644 index 0000000..5bafbe2 --- /dev/null +++ b/ofdgo_editor_resources.go @@ -0,0 +1,281 @@ +// 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 ( + "bufio" + "bytes" + "encoding/xml" + "io" + "maps" + "path" + "slices" + "strconv" + "strings" +) + +// editorResourceRefs 保存全包资源标识和直接文件引用,不以页面是否加载判断资源存活。 +type editorResourceRefs struct { + ids map[string]bool + files map[string]bool +} + +// pruneSourceResources 在保存副本中清理无引用字体和图片,保留其他资源及原始编辑状态。 +// 按GB/T 33190-2016附录A检查标识和路径引用,包括模板、注释、底纹、裁剪、版本和其他文档。 +// 全包检查保留孤立XML中的引用;未知命名空间、扩展数据或无法解析的XML使本次清理跳过。 +// 入参: parts 已修改和新增的包内条目 +// 返回: map[string]bool 可移除的二进制条目, error 读取错误 +func (e *Editor) pruneSourceResources(parts map[string][]byte) (map[string]bool, error) { + if len(parts) == 0 { + return nil, nil + } + reader := e.source.reader + names := make(map[string]bool) + for name := range reader.fileIndex { + names[name] = true + } + for name := range reader.files { + names[name] = true + } + for name := range parts { + names[name] = true + } + refs := editorResourceRefs{ids: make(map[string]bool), files: make(map[string]bool)} + var resources []string + for _, name := range slices.Sorted(maps.Keys(names)) { + if strings.HasSuffix(name, "/") { + continue + } + var input io.ReadCloser + if data, ok := parts[name]; ok { + input = io.NopCloser(bytes.NewReader(data)) + } else { + var err error + input, err = reader.openFile(name) + if err != nil { + return nil, err + } + } + resource, safe := refs.scan(input, name) + input.Close() + if !safe { + return nil, nil + } + if resource { + resources = append(resources, name) + } + } + removed := make(map[string]bool) + usedFiles := maps.Clone(refs.files) + updates := make(map[string][]byte) + for _, name := range resources { + data, ok := parts[name] + if !ok { + var err error + data, err = reader.readFile(name) + if err != nil { + return nil, err + } + } + root, err := parseEditorXML(data) + if err != nil { + return nil, nil + } + var patches []editorXMLPatch + for _, group := range root.children { + var members []editorXMLPatch + for _, node := range group.children { + field := "" + if group.name.Local == "Fonts" && node.name.Local == "Font" { + field = "FontFile" + } else if group.name.Local == "MultiMedias" && node.name.Local == "MultiMedia" { + field = "MediaFile" + } + if field == "" { + continue + } + id := editorResourceID(node.attr("ID")) + keep := id == "" || refs.ids[id] || field == "MediaFile" && node.attr("Type") != "Image" + if file := node.child(field); file != nil { + var value string + if err := xml.Unmarshal(data[file.start:file.end], &value); err != nil { + return nil, nil + } + if location := editorResourceLocation(name, root.attr("BaseLoc"), value); location != "" { + if keep { + usedFiles[location] = true + usedFiles[strings.ToLower(cleanPackagePath(resolveResourcePath(name, root.attr("BaseLoc"), value)))] = true + } else { + removed[location] = true + } + } + } + if !keep { + members = append(members, editorXMLPatch{start: node.start, end: node.end}) + } + } + if len(members) > 0 && len(members) == len(group.children) { + patches = append(patches, editorXMLPatch{start: group.start, end: group.end}) + } else { + patches = append(patches, members...) + } + } + if len(patches) != 0 { + updates[name] = editorPatchXML(data, patches) + } + } + for name := range usedFiles { + delete(removed, name) + } + maps.Copy(parts, updates) + for name := range parts { + if removed[strings.ToLower(cleanPackagePath(name))] { + delete(parts, name) + } + } + return removed, nil +} + +// scan 流式读取包内XML引用,二进制条目只探测文件头,不展开页面对象或图片像素。 +// 入参: input 文件流, name 包内路径 +// 返回: bool 是否资源索引, bool 是否可完整判断引用 +func (r *editorResourceRefs) scan(input io.Reader, name string) (bool, bool) { + buffer := bufio.NewReader(input) + header, err := buffer.Peek(512) + if err != nil && err != io.EOF { + return false, false + } + header = bytes.TrimSpace(bytes.TrimPrefix(header, []byte{0xef, 0xbb, 0xbf})) + if len(header) == 0 { + return false, err == io.EOF && !strings.EqualFold(path.Ext(name), ".xml") + } + if header[0] != '<' { + return false, !strings.EqualFold(path.Ext(name), ".xml") + } + decoder := xml.NewDecoder(buffer) + var stack []string + var text strings.Builder + resource, base := false, "" + for { + token, err := decoder.Token() + if err == io.EOF { + return resource, len(stack) == 0 + } + if err != nil { + return false, false + } + switch token := token.(type) { + case xml.StartElement: + if token.Name.Space != "" && token.Name.Space != ofdNamespace { + return false, false + } + if token.Name.Local == "CustomTags" || token.Name.Local == "Extensions" || token.Name.Local == "ExtendData" || token.Name.Local == "Data" { + return false, false + } + if len(stack) == 0 { + resource = token.Name.Local == "Res" + if resource { + for _, attr := range token.Attr { + if attr.Name.Local == "BaseLoc" { + base = attr.Value + } + } + } + } + for _, attr := range token.Attr { + if attr.Name.Space == "xmlns" || attr.Name.Local == "xmlns" || attr.Name.Space == "http://www.w3.org/XML/1998/namespace" { + continue + } + if attr.Name.Space != "" { + return false, false + } + if attr.Name.Local != "ID" { + r.reference(name, base, attr.Name.Local, attr.Value, resource) + } + } + stack = append(stack, token.Name.Local) + text.Reset() + case xml.CharData: + text.Write(token) + case xml.EndElement: + r.reference(name, base, token.Name.Local, text.String(), resource) + stack = stack[:len(stack)-1] + text.Reset() + case xml.Directive: + return false, false + } + } +} + +// reference 记录标准资源标识和路径引用,不将普通数字或资源自身ID视为引用。 +// 入参: name 当前文件, base 资源目录, key 字段, value 值, resource 是否资源索引 +func (r *editorResourceRefs) reference(name, base, key, value string, resource bool) { + value = strings.TrimSpace(value) + if value == "" { + return + } + switch key { + case "Font", "ResourceID", "Substitution", "ImageMask", "Relative", "DrawParam", "ColorSpace", "DefaultCS", "Thumbnail", "TemplateID", "PageID", "PageRef", "RefId": + if id := editorResourceID(value); id != "" { + r.ids[id] = true + } + return + case "DocRoot", "Signatures", "Cover", "PublicRes", "DocumentRes", "PageRes", "Annotations", "Attachments", "FileLoc", "FileRef", "Profile", "SignedValue", "SchemaLoc", "ExtendData", "File": + case "BaseLoc": + if resource { + return + } + case "FontFile", "MediaFile": + if resource { + return + } + default: + return + } + r.files[editorResourceLocation(name, base, value)] = true + r.files[strings.ToLower(cleanPackagePath(resolveResourcePath(name, base, value)))] = true +} + +// editorResourceID 规范化ST_ID与ST_RefID的十进制表示。 +// 入参: value 标识文本 +// 返回: string 标准十进制标识,无效时为空 +func editorResourceID(value string) string { + id, err := strconv.ParseUint(strings.TrimSpace(value), 10, 32) + if err != nil || id == 0 { + return "" + } + return strconv.FormatUint(id, 10) +} + +// editorResourceLocation 按ST_Loc与Res.BaseLoc解析路径,大小写折叠用于保守保留共享文件。 +// 入参: name 所在XML, base 资源目录, value 路径值 +// 返回: string 包内路径 +func editorResourceLocation(name, base, value string) string { + value = strings.ReplaceAll(strings.TrimSpace(value), "\\", "/") + if value == "" { + return "" + } + if strings.HasPrefix(value, "/") { + return strings.ToLower(cleanPackagePath(value)) + } + base = strings.ReplaceAll(base, "\\", "/") + dir := path.Dir(name) + if strings.HasPrefix(base, "/") { + dir = cleanPackagePath(base) + } else { + dir = path.Join(dir, base) + } + return strings.ToLower(cleanPackagePath(path.Join(dir, value))) +} diff --git a/ofdgo_editor_transform.go b/ofdgo_editor_transform.go index 6ce0698..b29fbaa 100644 --- a/ofdgo_editor_transform.go +++ b/ofdgo_editor_transform.go @@ -102,21 +102,18 @@ func (e *Editor) orientObjects(page int, ids []string, matrix Matrix) error { local := TranslationMatrix(-after.X, -after.Y).Multiply(matrix).Multiply(TranslationMatrix(before.X, before.Y)) *ctm = local.Multiply(NewMatrix(*ctm)).String() *boundary = editorBoxString(after) - if object.Type == "ImageObject" { - object.ImageObject.Clips = transformImageClips(object.ImageObject.Clips, local) + if clips := editorObjectClips(&object); *clips != nil && (*clips).TransFlag != nil && !*(*clips).TransFlag { + *clips = transformObjectClips(*clips, local) } objects[i] = object } return e.updateObjects(page, objects, true) } -// transformImageClips 同步变换不随对象CTM变化的裁剪区域,不修改原始裁剪数据。 -// 入参: clips 图片裁剪集合, matrix 对象边界坐标中的变换 +// transformObjectClips 变换裁剪区域,不修改原始裁剪数据。 +// 入参: clips 裁剪集合, matrix 对象边界坐标中的变换 // 返回: *Clips 变换后的裁剪集合 -func transformImageClips(clips *Clips, matrix Matrix) *Clips { - if clips == nil || clips.TransFlag == nil || *clips.TransFlag { - return clips - } +func transformObjectClips(clips *Clips, matrix Matrix) *Clips { result := *clips result.Clip = slices.Clone(clips.Clip) for i := range result.Clip { diff --git a/ofdgo_editor_xml.go b/ofdgo_editor_xml.go index 8f74438..d23849a 100644 --- a/ofdgo_editor_xml.go +++ b/ofdgo_editor_xml.go @@ -232,11 +232,12 @@ func editorXMLSupported(node *editorXML) bool { var allowed, children string switch node.name.Local { case "TextObject": - allowed, children = "ID Boundary CTM DrawParam Font Size HScale Weight ReadDirection CharDirection LineWidth MiterLimit Join Italic Visible Stroke Fill Alpha", "FillColor StrokeColor TextCode" + allowed, children = "ID Boundary CTM DrawParam Font Size HScale Weight ReadDirection CharDirection LineWidth MiterLimit Join Italic Visible Stroke Fill Alpha", "Clips FillColor StrokeColor TextCode" case "PathObject", "Path": allowed, children = "Boundary CTM LineWidth MiterLimit Join Cap Rule DashPattern DashOffset Visible Stroke Fill Alpha", "StrokeColor FillColor AbbreviatedData" if node.name.Local == "PathObject" { allowed += " ID DrawParam" + children += " Clips" } case "ImageObject": allowed, children = "ID Boundary CTM ResourceID ImageMask Visible Alpha", "Clips" diff --git a/ofdgo_renderer_image.go b/ofdgo_renderer_image.go index ea0aa40..da61f32 100644 --- a/ofdgo_renderer_image.go +++ b/ofdgo_renderer_image.go @@ -20,6 +20,7 @@ import ( "image" "image/color" "io" + "math" "github.com/tdewolff/canvas" canvasimage "github.com/tdewolff/canvas/image" @@ -79,7 +80,8 @@ func (r *Renderer) renderImage(ctx *canvas.Context, obj ImageObject, pageH float } } clipPath := intersectClipPath(parentClip, r.buildObjectClipPath(obj.Clips, pageH, box.X, box.Y, localCTM, parentCTM, boundaryInCTM)) - img = imageWithClip(img, clipPath, m) + img = imageWithClip(img, clipPath, m, 96) + m = m.Scale(imgW/float64(img.Bounds().Dx()), imgH/float64(img.Bounds().Dy())) img, pad := imageWithTransparentEdge(img) if pad > 0 { p := float64(pad) @@ -169,9 +171,10 @@ func imageWithMask(img, mask image.Image) image.Image { } // imageWithClip 应用图片裁剪区域 -// 入参: img 图片对象, clipPath 裁剪路径, m 图片变换矩阵 +// 低分辨率图片按最低精度细化蒙版,细化后的像素上限16Mi,不降低原图分辨率。 +// 入参: img 图片对象, clipPath 裁剪路径, m 图片变换矩阵, dpi 最低蒙版分辨率,0保留原图精度 // 返回: image.Image 裁剪后的图片对象 -func imageWithClip(img image.Image, clipPath *canvas.Path, m canvas.Matrix) image.Image { +func imageWithClip(img image.Image, clipPath *canvas.Path, m canvas.Matrix, dpi float64) image.Image { if img == nil || clipPath == nil || m.Det() == 0 { return img } @@ -197,6 +200,16 @@ func imageWithClip(img image.Image, clipPath *canvas.Path, m canvas.Matrix) imag } else if clipPath.Contains(imagePath) { return img } + scale := math.Max(math.Hypot(m[0][0], m[1][0]), math.Hypot(m[0][1], m[1][1])) * dpi / 25.4 + scale = math.Min(scale, math.Sqrt((16<<20)/(float64(w)*float64(h)))) + if scale > 1 { + nw, nh := int(float64(w)*scale), int(float64(h)*scale) + resized := image.NewNRGBA(image.Rect(0, 0, nw, nh)) + draw.CatmullRom.Scale(resized, resized.Bounds(), imagePixelSource(img), bounds, draw.Src, nil) + img = resized + m = m.Scale(float64(w)/float64(nw), float64(h)/float64(nh)) + bounds, w, h = resized.Bounds(), nw, nh + } clip := clipPath.Copy().Transform(m.Inv()) clipCanvas := canvas.New(float64(w), float64(h)) ctx := canvas.NewContext(clipCanvas) diff --git a/ofdgo_writer.go b/ofdgo_writer.go index eedf99d..656aab6 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -530,10 +530,12 @@ func (x *ofdXML) object(object GraphicObject, root bool) { if object.Type == "ImageObject" { x.clips(object.ImageObject.Clips) } else if object.Type == "PathObject" || object.Type == "Path" { + x.clips(object.PathObject.Clips) x.color("StrokeColor", stroke) x.color("FillColor", fill) x.text("AbbreviatedData", object.PathObject.AbbreviatedData) } else if object.Type == "TextObject" { + x.clips(object.TextObject.Clips) x.color("FillColor", fill) x.color("StrokeColor", stroke) for _, code := range object.TextObject.TextCode { @@ -548,7 +550,7 @@ func (x *ofdXML) object(object GraphicObject, root bool) { x.end(object.Type) } -// clips 写出图片路径裁剪,保留裁剪集合、区域及路径的变换 +// clips 写出对象路径裁剪,保留裁剪集合、区域及路径的变换 // 入参: clips 裁剪集合,nil不输出节点 func (x *ofdXML) clips(clips *Clips) { if clips == nil {