mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 13:56:32 +08:00
This commit is contained in:
1 parent
215d405c76
commit
71e67be9c1
14 files changed
+596
-36
No files matched your search
@@ -91,6 +91,11 @@
|
|||||||
</select>
|
</select>
|
||||||
<span>对象</span>
|
<span>对象</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="editor-group editor-erase">
|
||||||
|
<button id="eraseObjectButton" class="button" type="button" title="整对象擦除" aria-label="整对象擦除" aria-pressed="false">对象</button>
|
||||||
|
<button id="eraseRegionButton" class="button" type="button" title="区域擦除(视觉裁剪)" aria-label="区域擦除" aria-pressed="false">区域</button>
|
||||||
|
<span>擦除</span>
|
||||||
|
</div>
|
||||||
<div class="editor-group">
|
<div class="editor-group">
|
||||||
<button id="insertTextButton" class="button" type="button" aria-label="添加文字" aria-pressed="false">文字</button>
|
<button id="insertTextButton" class="button" type="button" aria-label="添加文字" aria-pressed="false">文字</button>
|
||||||
<button id="insertImageButton" class="button" type="button" aria-label="添加图片">图片</button>
|
<button id="insertImageButton" class="button" type="button" aria-label="添加图片">图片</button>
|
||||||
|
|||||||
@@ -119,7 +119,8 @@ a:focus-visible {
|
|||||||
grid-template-columns: repeat(2, max-content);
|
grid-template-columns: repeat(2, max-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-transform {
|
.editor-transform,
|
||||||
|
.editor-erase {
|
||||||
grid-template-columns: max-content;
|
grid-template-columns: max-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1400,6 +1401,7 @@ button.outline-link:hover {
|
|||||||
|
|
||||||
.edit-object:hover,
|
.edit-object:hover,
|
||||||
.edit-object.selected,
|
.edit-object.selected,
|
||||||
|
.edit-object.erasing,
|
||||||
.edit-selection,
|
.edit-selection,
|
||||||
.edit-crop {
|
.edit-crop {
|
||||||
outline: calc(1px / var(--surface-scale)) solid var(--accent);
|
outline: calc(1px / var(--surface-scale)) solid var(--accent);
|
||||||
@@ -1419,6 +1421,11 @@ button.outline-link:hover {
|
|||||||
background: rgba(15, 123, 104, 0.04);
|
background: rgba(15, 123, 104, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-object.erasing {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
outline-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
.edit-handle {
|
.edit-handle {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
+35
-3
@@ -52,6 +52,7 @@ const state = {
|
|||||||
searchIndex: -1,
|
searchIndex: -1,
|
||||||
navigationScroll: new Map(),
|
navigationScroll: new Map(),
|
||||||
fontSyncPending: false,
|
fontSyncPending: false,
|
||||||
|
fontCatalogLoading: false,
|
||||||
fontRenderPending: false,
|
fontRenderPending: false,
|
||||||
fontSyncing: false,
|
fontSyncing: false,
|
||||||
doc: null,
|
doc: null,
|
||||||
@@ -117,6 +118,8 @@ const el = {
|
|||||||
drawLineButton: document.querySelector("#drawLineButton"),
|
drawLineButton: document.querySelector("#drawLineButton"),
|
||||||
drawRectangleButton: document.querySelector("#drawRectangleButton"),
|
drawRectangleButton: document.querySelector("#drawRectangleButton"),
|
||||||
drawEllipseButton: document.querySelector("#drawEllipseButton"),
|
drawEllipseButton: document.querySelector("#drawEllipseButton"),
|
||||||
|
eraseObjectButton: document.querySelector("#eraseObjectButton"),
|
||||||
|
eraseRegionButton: document.querySelector("#eraseRegionButton"),
|
||||||
shapeFill: document.querySelector("#shapeFill"),
|
shapeFill: document.querySelector("#shapeFill"),
|
||||||
shapeFillColor: document.querySelector("#shapeFillColor"),
|
shapeFillColor: document.querySelector("#shapeFillColor"),
|
||||||
shapeStroke: document.querySelector("#shapeStroke"),
|
shapeStroke: document.querySelector("#shapeStroke"),
|
||||||
@@ -246,7 +249,7 @@ const fontManager = new FontManager({
|
|||||||
onChange: scheduleFontSync,
|
onChange: scheduleFontSync,
|
||||||
onPermissionChange: updateFontPermissionHint,
|
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());
|
el.editorTools.addEventListener("scroll", () => fontPicker.position());
|
||||||
|
|
||||||
const canvasEditor = new CanvasEditor(el.viewerPanel, {
|
const canvasEditor = new CanvasEditor(el.viewerPanel, {
|
||||||
@@ -274,6 +277,13 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
|
|||||||
if (state.fontRenderPending) window.setTimeout(refreshPendingFonts, 0);
|
if (state.fontRenderPending) window.setTimeout(refreshPendingFonts, 0);
|
||||||
},
|
},
|
||||||
onDelete: (item) => changeDocument(item.items ? "ofdgoDeleteObjects" : "ofdgoDeleteObject", item),
|
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,
|
onEdit: editCanvasObject,
|
||||||
drawStyle: shapeStyle,
|
drawStyle: shapeStyle,
|
||||||
onTool: updateDrawingControls,
|
onTool: updateDrawingControls,
|
||||||
@@ -426,7 +436,8 @@ el.resetCropButton.addEventListener("click", () => {
|
|||||||
});
|
});
|
||||||
editorClick(el.undoButton, () => changeDocument("ofdgoUndo"));
|
editorClick(el.undoButton, () => changeDocument("ofdgoUndo"));
|
||||||
editorClick(el.redoButton, () => changeDocument("ofdgoRedo"));
|
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, () => {
|
editorClick(button, () => {
|
||||||
if (!state.editing || document.body.hasAttribute("aria-busy")) {
|
if (!state.editing || document.body.hasAttribute("aria-busy")) {
|
||||||
return;
|
return;
|
||||||
@@ -987,6 +998,23 @@ function editorFonts(item) {
|
|||||||
return fonts;
|
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) {
|
function updateTextFonts(item, refresh = false) {
|
||||||
const id = item?.type === "TextObject" && !item.draft ? item.font : null;
|
const id = item?.type === "TextObject" && !item.draft ? item.font : null;
|
||||||
if (refresh || state.textFontID !== id) {
|
if (refresh || state.textFontID !== id) {
|
||||||
@@ -1193,6 +1221,10 @@ function updateDrawingControls() {
|
|||||||
button.disabled = disabled || !pageCan("insert");
|
button.disabled = disabled || !pageCan("insert");
|
||||||
button.setAttribute("aria-pressed", String(tool === name));
|
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));
|
el.selectObjectButton.setAttribute("aria-pressed", String(canvasEditor.enabled && !tool));
|
||||||
const styleDisabled = disabled || (canvasEditor.selected?.type === "PathObject"
|
const styleDisabled = disabled || (canvasEditor.selected?.type === "PathObject"
|
||||||
? !canEditObject(canvasEditor.selected, "update") : !pageCan("insert"));
|
? !canEditObject(canvasEditor.selected, "update") : !pageCan("insert"));
|
||||||
@@ -1224,7 +1256,7 @@ async function changeDocument(name, item, ...args) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
setEditorInfo(doc);
|
setEditorInfo(doc);
|
||||||
if (!item || name === "ofdgoDeleteObject" || name === "ofdgoDeleteObjects") {
|
if (!item || name === "ofdgoDeleteObject" || name === "ofdgoDeleteObjects" || name === "ofdgoEraseObjects") {
|
||||||
canvasEditor.clear();
|
canvasEditor.clear();
|
||||||
}
|
}
|
||||||
if (name === "ofdgoCopyObjects" || name === "ofdgoPasteObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") {
|
if (name === "ofdgoCopyObjects" || name === "ofdgoPasteObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") {
|
||||||
|
|||||||
@@ -358,7 +358,8 @@ export class CanvasEditor {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.tool) {
|
if (this.tool) {
|
||||||
this.startShape(event);
|
if (this.tool.startsWith("erase-")) this.startErase(event);
|
||||||
|
else this.startShape(event);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.target.dataset.corner) {
|
if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.target.dataset.corner) {
|
||||||
@@ -425,6 +426,10 @@ export class CanvasEditor {
|
|||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
drag.pointer = event;
|
drag.pointer = event;
|
||||||
|
if (drag.erase) {
|
||||||
|
this.moveErase(event, drag);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (drag.crop) {
|
if (drag.crop) {
|
||||||
const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.page, drag.rotation);
|
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);
|
const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.page, drag.rotation);
|
||||||
@@ -495,6 +500,14 @@ export class CanvasEditor {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.move(event);
|
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) {
|
if (drag.crop) {
|
||||||
this.drag = null;
|
this.drag = null;
|
||||||
this.viewer.releasePointerCapture(drag.pointerID);
|
this.viewer.releasePointerCapture(drag.pointerID);
|
||||||
@@ -550,6 +563,7 @@ export class CanvasEditor {
|
|||||||
const drag = this.drag;
|
const drag = this.drag;
|
||||||
this.drag = null;
|
this.drag = null;
|
||||||
if (drag) {
|
if (drag) {
|
||||||
|
for (const item of drag.erased || []) item.node.classList.remove("erasing");
|
||||||
if (drag.crop) {
|
if (drag.crop) {
|
||||||
this.crop.box = drag.box;
|
this.crop.box = drag.box;
|
||||||
this.paintCrop();
|
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) {
|
startMarquee(event, additive) {
|
||||||
const surface = event.target.closest(".page-surface"), page = this.pages.get(surface);
|
const surface = event.target.closest(".page-surface"), page = this.pages.get(surface);
|
||||||
if (!page) {
|
if (!page) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
const DATABASE = "ofdgo";
|
const DATABASE = "ofdgo";
|
||||||
|
|
||||||
export class FontPicker {
|
export class FontPicker {
|
||||||
constructor(input, toggle, list, onChange) {
|
constructor(input, toggle, list, onChange, onOpen) {
|
||||||
Object.assign(this, { input, toggle, list, onChange, fonts: [], value: "", active: -1, open: false });
|
Object.assign(this, { input, toggle, list, onChange, onOpen, fonts: [], value: "", active: -1, open: false });
|
||||||
input.addEventListener("input", () => this.show(input.value));
|
input.addEventListener("input", () => this.show(input.value));
|
||||||
input.addEventListener("focus", () => input.select());
|
input.addEventListener("focus", () => input.select());
|
||||||
input.addEventListener("click", () => { if (!this.open) this.show(); });
|
input.addEventListener("click", () => { if (!this.open) this.show(); });
|
||||||
@@ -73,6 +73,7 @@ export class FontPicker {
|
|||||||
this.position();
|
this.position();
|
||||||
const selected = this.matches.findIndex(({ font, index }) => !font.disabled && String(index) === this.value);
|
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.activate(selected < 0 ? this.matches.findIndex(({ font }) => !font.disabled) : selected);
|
||||||
|
this.onOpen?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
position() {
|
position() {
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ func RunWASM() {
|
|||||||
registerCallback("ofdgoTransformObjects", transformObjects)
|
registerCallback("ofdgoTransformObjects", transformObjects)
|
||||||
registerCallback("ofdgoAlignObjects", alignObjects)
|
registerCallback("ofdgoAlignObjects", alignObjects)
|
||||||
registerCallback("ofdgoDeleteObjects", deleteObjects)
|
registerCallback("ofdgoDeleteObjects", deleteObjects)
|
||||||
|
registerCallback("ofdgoEraseObjects", eraseObjects)
|
||||||
registerCallback("ofdgoCopyObjects", copyObjects)
|
registerCallback("ofdgoCopyObjects", copyObjects)
|
||||||
registerCallback("ofdgoCaptureObjects", captureObjects)
|
registerCallback("ofdgoCaptureObjects", captureObjects)
|
||||||
registerCallback("ofdgoPasteObjects", pasteObjects)
|
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 提交库层批量操作,无修改时复用当前预览。
|
// changeObjects 提交库层批量操作,无修改时复用当前预览。
|
||||||
// 入参: apply 待执行的编辑操作
|
// 入参: apply 待执行的编辑操作
|
||||||
// 返回: any 文档信息, error 错误信息
|
// 返回: any 文档信息, error 错误信息
|
||||||
|
|||||||
+40
-16
@@ -30,6 +30,7 @@ import (
|
|||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/tdewolff/font"
|
"github.com/tdewolff/font"
|
||||||
|
"github.com/xiaoqidun/jbig2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Editor 编辑OFD文档,长度单位为毫米,页面索引从0开始,实例需串行使用
|
// Editor 编辑OFD文档,长度单位为毫米,页面索引从0开始,实例需串行使用
|
||||||
@@ -346,7 +347,8 @@ func editorFontName(sfnt *font.SFNT, names ...font.NameID) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddImage 注册PNG或JPEG图片,保留原始编码,重复资源复用标识,引用后写入文档
|
// AddImage 注册PNG或JPEG图片,重复资源复用标识,引用后写入文档
|
||||||
|
// 不透明纯黑白PNG仅在无损JBIG2编码更小时转换,其他图片保留原始编码。
|
||||||
// 入参: data 图片数据
|
// 入参: data 图片数据
|
||||||
// 返回: string 图片资源标识, error 错误信息
|
// 返回: string 图片资源标识, error 错误信息
|
||||||
func (e *Editor) AddImage(data []byte) (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 {
|
if err := e.prepareSourceIDs(); err != nil {
|
||||||
return "", err
|
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()
|
id := e.nextID()
|
||||||
extension := format
|
extension := format
|
||||||
if extension == "jpeg" {
|
if extension == "jpeg" {
|
||||||
extension = "jpg"
|
extension = "jpg"
|
||||||
|
} else if extension == "jbig2" {
|
||||||
|
extension = "jb2"
|
||||||
}
|
}
|
||||||
name := "Image_" + id + "." + extension
|
name := "Image_" + id + "." + extension
|
||||||
e.resources = append(e.resources, editorResource{
|
e.resources = append(e.resources, editorResource{
|
||||||
@@ -380,8 +389,23 @@ func (e *Editor) AddImage(data []byte) (string, error) {
|
|||||||
return id, nil
|
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
|
// AddObject 按添加顺序放入正文图层,支持文字、路径和图片,自动分配对象ID
|
||||||
// 支持基本颜色、直接资源引用及图片的路径裁剪,不支持动作、渐变及复合图元
|
// 支持基本颜色、直接资源引用及路径裁剪,不支持动作、渐变及复合图元
|
||||||
// 入参: page 页面索引, object 对象内容,添加后不再引用调用方的可变数据
|
// 入参: page 页面索引, object 对象内容,添加后不再引用调用方的可变数据
|
||||||
// 返回: string 对象标识, error 错误信息
|
// 返回: string 对象标识, error 错误信息
|
||||||
func (e *Editor) AddObject(page int, object GraphicObject) (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" {
|
} else if object.Type == "ImageObject" {
|
||||||
object.ImageObject.CTM = fmt.Sprintf("%s 0 0 %s 0 0", ofdNumber(box.W), ofdNumber(box.H))
|
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" {
|
if drawParam != "" || len(actions) != 0 {
|
||||||
return GraphicObject{}, fmt.Errorf("draw parameter references, non-image clips and actions are not supported for creation")
|
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
|
return GraphicObject{}, err
|
||||||
}
|
}
|
||||||
if alpha != nil && (*alpha < 0 || *alpha > 255) {
|
if alpha != nil && (*alpha < 0 || *alpha > 255) {
|
||||||
@@ -599,23 +623,23 @@ func (e *Editor) prepareObject(id string, object GraphicObject) (GraphicObject,
|
|||||||
return cloneEditorObject(object)
|
return cloneEditorObject(object)
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateImageClips 校验图片的路径裁剪,仅接受无资源引用及嵌套裁剪的路径
|
// validateObjectClips 校验对象的路径裁剪,仅接受无资源引用及嵌套裁剪的路径
|
||||||
// 入参: clips 图片裁剪集合,nil表示无裁剪
|
// 入参: clips 裁剪集合,nil表示无裁剪
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
func (e *Editor) validateImageClips(clips *Clips) error {
|
func (e *Editor) validateObjectClips(clips *Clips) error {
|
||||||
if clips == nil {
|
if clips == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if len(clips.Clip) == 0 {
|
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 {
|
for _, clip := range clips.Clip {
|
||||||
if len(clip.Area) == 0 {
|
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 {
|
for _, area := range clip.Area {
|
||||||
if len(area.Path) == 0 || area.DrawParam != "" || len(area.Text) != 0 {
|
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 area.CTM != "" {
|
||||||
if _, err := creationNumbers(area.CTM, 6); err != nil {
|
if _, err := creationNumbers(area.CTM, 6); err != nil {
|
||||||
@@ -623,8 +647,8 @@ func (e *Editor) validateImageClips(clips *Clips) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, path := range area.Path {
|
for _, path := range area.Path {
|
||||||
if path.ID != "" {
|
if path.ID != "" || path.Clips != nil {
|
||||||
return fmt.Errorf("clip paths must not have object IDs")
|
return fmt.Errorf("clip paths must not have object IDs or nested clips")
|
||||||
}
|
}
|
||||||
if _, err := e.prepareObject("", GraphicObject{Type: "PathObject", PathObject: path}); err != nil {
|
if _, err := e.prepareObject("", GraphicObject{Type: "PathObject", PathObject: path}); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -726,12 +750,12 @@ func transformEditorObject(object GraphicObject, dx, dy, scale float64) (Graphic
|
|||||||
boundary, ctm = &object.PathObject.Boundary, &object.PathObject.CTM
|
boundary, ctm = &object.PathObject.Boundary, &object.PathObject.CTM
|
||||||
case "ImageObject":
|
case "ImageObject":
|
||||||
boundary, ctm = &object.ImageObject.Boundary, &object.ImageObject.CTM
|
boundary, ctm = &object.ImageObject.Boundary, &object.ImageObject.CTM
|
||||||
if scale != 1 {
|
|
||||||
object.ImageObject.Clips = transformImageClips(object.ImageObject.Clips, Matrix{a: scale, d: scale})
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
return GraphicObject{}, fmt.Errorf("unsupported object type %q", object.Type)
|
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)
|
box, err := ParseBox(*boundary)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return GraphicObject{}, err
|
return GraphicObject{}, err
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -382,12 +382,21 @@ func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte) (int64,
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
maps.Copy(parts, fonts)
|
maps.Copy(parts, fonts)
|
||||||
|
removed, err := e.pruneSourceResources(parts)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
reader := e.source.reader
|
reader := e.source.reader
|
||||||
remaining := maps.Clone(reader.files)
|
remaining := maps.Clone(reader.files)
|
||||||
if remaining == nil {
|
if remaining == nil {
|
||||||
remaining = make(map[string][]byte)
|
remaining = make(map[string][]byte)
|
||||||
}
|
}
|
||||||
maps.Copy(remaining, parts)
|
maps.Copy(remaining, parts)
|
||||||
|
for name := range remaining {
|
||||||
|
if removed[strings.ToLower(cleanPackagePath(name))] {
|
||||||
|
delete(remaining, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
output := &ofdCountingWriter{writer: writer}
|
output := &ofdCountingWriter{writer: writer}
|
||||||
archive := zip.NewWriter(output)
|
archive := zip.NewWriter(output)
|
||||||
if reader.Zip != nil {
|
if reader.Zip != nil {
|
||||||
@@ -401,6 +410,9 @@ func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte) (int64,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := cleanPackagePath(file.Name)
|
name := cleanPackagePath(file.Name)
|
||||||
|
if removed[strings.ToLower(name)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if data, ok := remaining[name]; ok {
|
if data, ok := remaining[name]; ok {
|
||||||
header := file.FileHeader
|
header := file.FileHeader
|
||||||
entry, err := archive.CreateHeader(&header)
|
entry, err := archive.CreateHeader(&header)
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
}
|
||||||
@@ -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))
|
local := TranslationMatrix(-after.X, -after.Y).Multiply(matrix).Multiply(TranslationMatrix(before.X, before.Y))
|
||||||
*ctm = local.Multiply(NewMatrix(*ctm)).String()
|
*ctm = local.Multiply(NewMatrix(*ctm)).String()
|
||||||
*boundary = editorBoxString(after)
|
*boundary = editorBoxString(after)
|
||||||
if object.Type == "ImageObject" {
|
if clips := editorObjectClips(&object); *clips != nil && (*clips).TransFlag != nil && !*(*clips).TransFlag {
|
||||||
object.ImageObject.Clips = transformImageClips(object.ImageObject.Clips, local)
|
*clips = transformObjectClips(*clips, local)
|
||||||
}
|
}
|
||||||
objects[i] = object
|
objects[i] = object
|
||||||
}
|
}
|
||||||
return e.updateObjects(page, objects, true)
|
return e.updateObjects(page, objects, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// transformImageClips 同步变换不随对象CTM变化的裁剪区域,不修改原始裁剪数据。
|
// transformObjectClips 变换裁剪区域,不修改原始裁剪数据。
|
||||||
// 入参: clips 图片裁剪集合, matrix 对象边界坐标中的变换
|
// 入参: clips 裁剪集合, matrix 对象边界坐标中的变换
|
||||||
// 返回: *Clips 变换后的裁剪集合
|
// 返回: *Clips 变换后的裁剪集合
|
||||||
func transformImageClips(clips *Clips, matrix Matrix) *Clips {
|
func transformObjectClips(clips *Clips, matrix Matrix) *Clips {
|
||||||
if clips == nil || clips.TransFlag == nil || *clips.TransFlag {
|
|
||||||
return clips
|
|
||||||
}
|
|
||||||
result := *clips
|
result := *clips
|
||||||
result.Clip = slices.Clone(clips.Clip)
|
result.Clip = slices.Clone(clips.Clip)
|
||||||
for i := range result.Clip {
|
for i := range result.Clip {
|
||||||
|
|||||||
+2
-1
@@ -232,11 +232,12 @@ func editorXMLSupported(node *editorXML) bool {
|
|||||||
var allowed, children string
|
var allowed, children string
|
||||||
switch node.name.Local {
|
switch node.name.Local {
|
||||||
case "TextObject":
|
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":
|
case "PathObject", "Path":
|
||||||
allowed, children = "Boundary CTM LineWidth MiterLimit Join Cap Rule DashPattern DashOffset Visible Stroke Fill Alpha", "StrokeColor FillColor AbbreviatedData"
|
allowed, children = "Boundary CTM LineWidth MiterLimit Join Cap Rule DashPattern DashOffset Visible Stroke Fill Alpha", "StrokeColor FillColor AbbreviatedData"
|
||||||
if node.name.Local == "PathObject" {
|
if node.name.Local == "PathObject" {
|
||||||
allowed += " ID DrawParam"
|
allowed += " ID DrawParam"
|
||||||
|
children += " Clips"
|
||||||
}
|
}
|
||||||
case "ImageObject":
|
case "ImageObject":
|
||||||
allowed, children = "ID Boundary CTM ResourceID ImageMask Visible Alpha", "Clips"
|
allowed, children = "ID Boundary CTM ResourceID ImageMask Visible Alpha", "Clips"
|
||||||
|
|||||||
+16
-3
@@ -20,6 +20,7 @@ import (
|
|||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
|
|
||||||
"github.com/tdewolff/canvas"
|
"github.com/tdewolff/canvas"
|
||||||
canvasimage "github.com/tdewolff/canvas/image"
|
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))
|
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)
|
img, pad := imageWithTransparentEdge(img)
|
||||||
if pad > 0 {
|
if pad > 0 {
|
||||||
p := float64(pad)
|
p := float64(pad)
|
||||||
@@ -169,9 +171,10 @@ func imageWithMask(img, mask image.Image) image.Image {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// imageWithClip 应用图片裁剪区域
|
// imageWithClip 应用图片裁剪区域
|
||||||
// 入参: img 图片对象, clipPath 裁剪路径, m 图片变换矩阵
|
// 低分辨率图片按最低精度细化蒙版,细化后的像素上限16Mi,不降低原图分辨率。
|
||||||
|
// 入参: img 图片对象, clipPath 裁剪路径, m 图片变换矩阵, dpi 最低蒙版分辨率,0保留原图精度
|
||||||
// 返回: image.Image 裁剪后的图片对象
|
// 返回: 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 {
|
if img == nil || clipPath == nil || m.Det() == 0 {
|
||||||
return img
|
return img
|
||||||
}
|
}
|
||||||
@@ -197,6 +200,16 @@ func imageWithClip(img image.Image, clipPath *canvas.Path, m canvas.Matrix) imag
|
|||||||
} else if clipPath.Contains(imagePath) {
|
} else if clipPath.Contains(imagePath) {
|
||||||
return img
|
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())
|
clip := clipPath.Copy().Transform(m.Inv())
|
||||||
clipCanvas := canvas.New(float64(w), float64(h))
|
clipCanvas := canvas.New(float64(w), float64(h))
|
||||||
ctx := canvas.NewContext(clipCanvas)
|
ctx := canvas.NewContext(clipCanvas)
|
||||||
|
|||||||
+3
-1
@@ -530,10 +530,12 @@ func (x *ofdXML) object(object GraphicObject, root bool) {
|
|||||||
if object.Type == "ImageObject" {
|
if object.Type == "ImageObject" {
|
||||||
x.clips(object.ImageObject.Clips)
|
x.clips(object.ImageObject.Clips)
|
||||||
} else if object.Type == "PathObject" || object.Type == "Path" {
|
} else if object.Type == "PathObject" || object.Type == "Path" {
|
||||||
|
x.clips(object.PathObject.Clips)
|
||||||
x.color("StrokeColor", stroke)
|
x.color("StrokeColor", stroke)
|
||||||
x.color("FillColor", fill)
|
x.color("FillColor", fill)
|
||||||
x.text("AbbreviatedData", object.PathObject.AbbreviatedData)
|
x.text("AbbreviatedData", object.PathObject.AbbreviatedData)
|
||||||
} else if object.Type == "TextObject" {
|
} else if object.Type == "TextObject" {
|
||||||
|
x.clips(object.TextObject.Clips)
|
||||||
x.color("FillColor", fill)
|
x.color("FillColor", fill)
|
||||||
x.color("StrokeColor", stroke)
|
x.color("StrokeColor", stroke)
|
||||||
for _, code := range object.TextObject.TextCode {
|
for _, code := range object.TextObject.TextCode {
|
||||||
@@ -548,7 +550,7 @@ func (x *ofdXML) object(object GraphicObject, root bool) {
|
|||||||
x.end(object.Type)
|
x.end(object.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
// clips 写出图片路径裁剪,保留裁剪集合、区域及路径的变换
|
// clips 写出对象路径裁剪,保留裁剪集合、区域及路径的变换
|
||||||
// 入参: clips 裁剪集合,nil不输出节点
|
// 入参: clips 裁剪集合,nil不输出节点
|
||||||
func (x *ofdXML) clips(clips *Clips) {
|
func (x *ofdXML) clips(clips *Clips) {
|
||||||
if clips == nil {
|
if clips == nil {
|
||||||
|
|||||||
Reference in new issue
Block a user