feat(编辑能力): 优化水印选择,完善注解编辑
build.yaml / build (push) Successful in 37s

This commit is contained in:
xiaoqidun committed 2026-09-18 02:19:06 +08:00
1 parent ff06840bcd
commit ed11ade4e8
11 files changed
+468 -37

No files matched your search

+6 -2
View File
@@ -265,9 +265,13 @@
</form> </form>
</dialog> </dialog>
<dialog id="annotationNote" class="form-dialog" aria-label="注解"> <dialog id="annotationNote" class="form-dialog" aria-label="注解">
<form method="dialog"> <form id="annotationForm" method="dialog">
<div id="annotationDetail" class="annotation-detail"></div> <div id="annotationDetail" class="annotation-detail"></div>
<div class="form-actions"><button class="button" type="submit" autofocus>关闭</button></div> <div id="annotationFields" hidden>
<div class="form-row"><label for="annotationRemark">说明</label><textarea id="annotationRemark" rows="4"></textarea></div>
<div class="form-row"><label for="annotationCreator">作者</label><input id="annotationCreator" type="text"></div>
</div>
<div class="form-actions"><div id="annotationStatus" class="form-status" role="status"></div><button id="annotationClose" class="button" type="button" autofocus>关闭</button><button id="annotationSubmit" class="button" type="submit" hidden>确定</button></div>
</form> </form>
</dialog> </dialog>
<dialog id="pagePanel" class="form-dialog" aria-label="页面设置"> <dialog id="pagePanel" class="form-dialog" aria-label="页面设置">
+8 -1
View File
@@ -798,7 +798,8 @@ button:not(:disabled):hover {
gap: 8px; gap: 8px;
} }
.form-actions > button:only-child { .form-actions > button:only-child,
.form-actions > button:has(+ button[hidden]) {
grid-column: -2; grid-column: -2;
} }
@@ -1297,6 +1298,8 @@ button.outline-link:hover {
.navigation-tabs[hidden], .navigation-tabs[hidden],
.page-selection-tools[hidden], .page-selection-tools[hidden],
.form-row[hidden], .form-row[hidden],
.form-actions > [hidden],
.annotation-detail[hidden],
.tool-group[hidden], .tool-group[hidden],
.page-list[hidden], .page-list[hidden],
.page-frame[hidden], .page-frame[hidden],
@@ -1513,6 +1516,10 @@ button.outline-link:hover {
cursor: default; cursor: default;
} }
.edit-object.edit-background:not(.selected):not(.erasing) {
outline: none;
}
.edit-object.edit-contour, .edit-object.edit-contour,
.edit-object.edit-contour.selected, .edit-object.edit-contour.selected,
.edit-object.edit-contour.erasing { .edit-object.edit-contour.erasing {
+61 -6
View File
@@ -27,6 +27,7 @@ let textMeasure = null;
const state = { const state = {
composite: null, composite: null,
annotationEdit: null,
ready: false, ready: false,
wasmExited: false, wasmExited: false,
wasmSeq: 0, wasmSeq: 0,
@@ -334,6 +335,13 @@ const el = {
signatureSummary: document.querySelector("#signatureSummary"), signatureSummary: document.querySelector("#signatureSummary"),
signatureList: document.querySelector("#signatureList"), signatureList: document.querySelector("#signatureList"),
annotationNote: document.querySelector("#annotationNote"), annotationNote: document.querySelector("#annotationNote"),
annotationForm: document.querySelector("#annotationForm"),
annotationFields: document.querySelector("#annotationFields"),
annotationRemark: document.querySelector("#annotationRemark"),
annotationCreator: document.querySelector("#annotationCreator"),
annotationStatus: document.querySelector("#annotationStatus"),
annotationClose: document.querySelector("#annotationClose"),
annotationSubmit: document.querySelector("#annotationSubmit"),
annotationDetail: document.querySelector("#annotationDetail"), annotationDetail: document.querySelector("#annotationDetail"),
docFontList: document.querySelector("#docFontList"), docFontList: document.querySelector("#docFontList"),
docFontSummary: document.querySelector("#docFontSummary"), docFontSummary: document.querySelector("#docFontSummary"),
@@ -363,7 +371,9 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
setStatus(reason || pageStatus(state.pageIndex, state.doc.pageCount)); setStatus(reason || pageStatus(state.pageIndex, state.doc.pageCount));
} }
}, },
onTransform: (item, change) => changeDocument(item.items ? "ofdgoTransformObjects" : "ofdgoTransformObject", item, onTransform: (item, change) => (item.items || [item]).every(object => object.type === "Annotation")
? changeAnnotations(item, "move", change.x, change.y)
: changeDocument(item.items ? "ofdgoTransformObjects" : "ofdgoTransformObject", item,
change.x + item.x * (1 - change.scale), change.y + item.y * (1 - change.scale), change.scale), change.x + item.x * (1 - change.scale), change.y + item.y * (1 - change.scale), change.scale),
onNudgeChange: () => { onNudgeChange: () => {
updatePendingChanges(); updatePendingChanges();
@@ -379,8 +389,13 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
syncSelection(); syncSelection();
if (state.fontRenderPending) window.setTimeout(refreshPendingFonts, 0); if (state.fontRenderPending) window.setTimeout(refreshPendingFonts, 0);
}, },
onDelete: (item) => changeDocument(item.items ? "ofdgoDeleteObjects" : "ofdgoDeleteObject", item), onDelete: (item) => (item.items || [item]).every(object => object.type === "Annotation")
? changeAnnotations(item, "delete") : changeDocument(item.items ? "ofdgoDeleteObjects" : "ofdgoDeleteObject", item),
onErase: (items, box, points) => { onErase: (items, box, points) => {
if (items.some(item => item.type === "Annotation")) {
if (box || !items.every(item => item.type === "Annotation")) { setStatus(box ? "注解暂不支持局部擦除" : "注解与正文需分别操作"); return false; }
return changeAnnotations({ items, index: items[0].index, id: items.map(item => item.id) }, "delete");
}
const blocked = items.find(item => !canEditObject(item, box ? "arrange" : "delete")); const blocked = items.find(item => !canEditObject(item, box ? "arrange" : "delete"));
if (blocked) { setStatus(objectEditReason(blocked) || "对象暂不可擦除"); return false; } if (blocked) { setStatus(objectEditReason(blocked) || "对象暂不可擦除"); return false; }
const item = { index: items[0].index, id: items.map(item => item.id), scoped: items.every(item => item.scoped) }; const item = { index: items[0].index, id: items.map(item => item.id), scoped: items.every(item => item.scoped) };
@@ -480,6 +495,16 @@ el.infoForm.addEventListener("submit", async event => {
event.preventDefault(); event.preventDefault();
if (await changeDocument("ofdgoUpdateInfo", null, el.infoTitle.value, el.infoAuthor.value, el.infoSubject.value)) el.infoPanel.close(); if (await changeDocument("ofdgoUpdateInfo", null, el.infoTitle.value, el.infoAuthor.value, el.infoSubject.value)) el.infoPanel.close();
}); });
el.annotationClose.addEventListener("click", () => el.annotationNote.close());
el.annotationNote.addEventListener("close", () => { state.annotationEdit = null; });
el.annotationForm.addEventListener("submit", async event => {
if (!state.annotationEdit) return;
event.preventDefault();
if (await changeAnnotations(state.annotationEdit, "update", el.annotationRemark.value, el.annotationCreator.value)) {
el.annotationNote.close();
canvasEditor.focus();
}
});
el.paragraphForm.addEventListener("submit", async event => { el.paragraphForm.addEventListener("submit", async event => {
event.preventDefault(); event.preventDefault();
if (document.body.hasAttribute("aria-busy") || !el.paragraphForm.reportValidity()) return; if (document.body.hasAttribute("aria-busy") || !el.paragraphForm.reportValidity()) return;
@@ -1370,6 +1395,18 @@ async function createDocument(event) {
async function editCanvasObject(item) { async function editCanvasObject(item) {
if (!state.editing || document.body.hasAttribute("aria-busy") || canvasEditor.input || item.items) return; if (!state.editing || document.body.hasAttribute("aria-busy") || canvasEditor.input || item.items) return;
if (item.type === "Annotation") {
state.annotationEdit = item;
el.annotationRemark.value = item.remark || "";
el.annotationCreator.value = item.creator || "";
el.annotationFields.hidden = el.annotationSubmit.hidden = false;
el.annotationDetail.hidden = true;
el.annotationStatus.textContent = "";
el.annotationClose.textContent = "取消";
el.annotationNote.showModal();
el.annotationRemark.focus();
return;
}
if (canEditObject(item, "enter")) return enterCompositeScope(item.index, item.id); if (canEditObject(item, "enter")) return enterCompositeScope(item.index, item.id);
if (item.type === "TextObject") { if (item.type === "TextObject") {
if (!confirmTextReflow(item)) return; if (!confirmTextReflow(item)) return;
@@ -1403,7 +1440,17 @@ async function editCanvasObject(item) {
function mountEditorObjects(index, page, surface) { function mountEditorObjects(index, page, surface) {
const scope = state.composite?.index === index ? state.composite : null; const scope = state.composite?.index === index ? state.composite : null;
canvasEditor.mount(index, scope ? { ...page, objects: scope.objects } : page, surface); const annotations = state.renderAnnotations ? (page.annotations || []).filter(annotation => annotation.visible).map(annotation => ({
...annotation, id: `annotation:${annotation.id}`, type: "Annotation", width: annotation.width || 6, height: annotation.height || 6,
background: annotation.type === "Watermark" || annotation.x <= 0.1 && annotation.y <= 0.1
&& annotation.x + annotation.width >= page.width - 0.1 && annotation.y + annotation.height >= page.height - 0.1,
capabilities: { update: true, move: true, delete: true },
})) : [];
canvasEditor.mount(index, { ...page, objects: scope ? scope.objects : [...page.objects, ...annotations] }, surface);
}
function changeAnnotations(item, action, ...args) {
return changeDocument("ofdgoChangeAnnotations", { ...item, id: item.items ? item.id : [item.id] }, action, ...args);
} }
function resetCompositeScope() { function resetCompositeScope() {
@@ -1953,7 +2000,8 @@ async function changeDocument(name, item, ...args) {
rememberEditorView(before, revision); rememberEditorView(before, revision);
return true; return true;
} }
const clearSelection = !item || ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoEraseObjects", "ofdgoEraseObjectsPath"].includes(name); const clearSelection = !item || name === "ofdgoChangeAnnotations" && args[0] === "delete"
|| ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoEraseObjects", "ofdgoEraseObjectsPath"].includes(name);
if (name === "ofdgoCopyObjects" || name === "ofdgoPasteObjects" || name === "ofdgoGroupObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") { if (name === "ofdgoCopyObjects" || name === "ofdgoPasteObjects" || name === "ofdgoGroupObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") {
state.selectObjects = true; state.selectObjects = true;
canvasEditor.setTool(""); canvasEditor.setTool("");
@@ -1981,7 +2029,9 @@ async function changeDocument(name, item, ...args) {
} }
} catch (err) { } catch (err) {
if (openSeq === state.openSeq) { if (openSeq === state.openSeq) {
if (el.batchPagesPanel.open) { if (el.annotationNote.open) {
el.annotationStatus.textContent = err.message;
} else if (el.batchPagesPanel.open) {
el.batchPagesStatus.textContent = err.message; el.batchPagesStatus.textContent = err.message;
} else if (el.objectStylePanel.open) { } else if (el.objectStylePanel.open) {
el.objectStyleStatus.textContent = err.message; el.objectStyleStatus.textContent = err.message;
@@ -5270,6 +5320,10 @@ function mountAnnotationNotes(index, surface, annotations) {
note.addEventListener("click", () => { note.addEventListener("click", () => {
if (state.editing || document.body.hasAttribute("aria-busy")) return; if (state.editing || document.body.hasAttribute("aria-busy")) return;
el.annotationDetail.replaceChildren(); el.annotationDetail.replaceChildren();
el.annotationFields.hidden = el.annotationSubmit.hidden = true;
el.annotationDetail.hidden = false;
el.annotationStatus.textContent = "";
el.annotationClose.textContent = "关闭";
const content = document.createElement("div"); const content = document.createElement("div");
content.textContent = annotation.remark; content.textContent = annotation.remark;
el.annotationDetail.append(content); el.annotationDetail.append(content);
@@ -5746,7 +5800,7 @@ function updateObjectControls(item, reset = false) {
el.editObjectButton.disabled = disabled || Boolean(item.items) || !canEditObject(item, "enter") && (item.type === "PathObject" el.editObjectButton.disabled = disabled || Boolean(item.items) || !canEditObject(item, "enter") && (item.type === "PathObject"
|| !canEditObject(item, item.type === "TextObject" ? "reflow" : item.type === "ImageObject" ? "replaceImage" : "update")); || !canEditObject(item, item.type === "TextObject" ? "reflow" : item.type === "ImageObject" ? "replaceImage" : "update"));
el.editObjectButton.textContent = canEditObject(item, "enter") ? "进入" : item?.type === "ImageObject" ? "替换" : "修改"; el.editObjectButton.textContent = canEditObject(item, "enter") ? "进入" : item?.type === "ImageObject" ? "替换" : "修改";
el.editObjectButton.title = canEditObject(item, "enter") ? "进入组合" : item?.type === "ImageObject" ? "替换图片" : "修改对象"; el.editObjectButton.title = canEditObject(item, "enter") ? "进入组合" : item?.type === "ImageObject" ? "替换图片" : item?.type === "Annotation" ? "修改注解" : "修改对象";
el.editObjectButton.setAttribute("aria-label", el.editObjectButton.title); el.editObjectButton.setAttribute("aria-label", el.editObjectButton.title);
el.multiSelectButton.disabled = !state.editing || !canvasEditor.enabled || !state.ready || state.exporting; el.multiSelectButton.disabled = !state.editing || !canvasEditor.enabled || !state.ready || state.exporting;
const selection = selectedText(item); const selection = selectedText(item);
@@ -5866,6 +5920,7 @@ function setBusy(busy, text = "", percent = 0, status = "") {
el.pageForm.inert = busy; el.pageForm.inert = busy;
el.paragraphForm.inert = busy; el.paragraphForm.inert = busy;
el.infoForm.inert = busy; el.infoForm.inert = busy;
el.annotationForm.inert = busy;
el.importForm.inert = busy && !state.importing; el.importForm.inert = busy && !state.importing;
for (const input of [el.importFile, el.importRange, el.importPosition, el.importOutlines]) input.disabled = busy; for (const input of [el.importFile, el.importRange, el.importPosition, el.importOutlines]) input.disabled = busy;
el.importPages.disabled = busy || el.importRange.value !== "custom"; el.importPages.disabled = busy || el.importRange.value !== "custom";
+19 -8
View File
@@ -176,7 +176,10 @@ function paintShape(node, shape, box) {
} }
export function canEditObject(item, capability) { export function canEditObject(item, capability) {
return Boolean(item) && (item.items || [item]).every(object => Boolean(object.capabilities?.[capability])); if (!item) return false;
const objects = item.items || [item];
if (objects.some(object => object.type === "Annotation") && objects.some(object => object.type !== "Annotation")) return false;
return objects.every(object => Boolean(object.capabilities?.[capability] || capability === "move" && object.capabilities?.transform));
} }
export function missingGlyphMessage(diagnostic, action = "请更换字体") { export function missingGlyphMessage(diagnostic, action = "请更换字体") {
@@ -188,6 +191,7 @@ export function missingGlyphMessage(diagnostic, action = "请更换字体") {
} }
export function objectEditReason(item) { export function objectEditReason(item) {
if (item?.items?.some(object => object.type === "Annotation") && item.items.some(object => object.type !== "Annotation")) return "注解与正文需分别操作";
const labels = { fontUnavailable: "字体不可用", unsupportedColor: "暂不支持此颜色", unsupportedStyle: "暂不支持此样式", const labels = { fontUnavailable: "字体不可用", unsupportedColor: "暂不支持此颜色", unsupportedStyle: "暂不支持此样式",
unsupportedContainer: "暂不支持此对象结构", invalidObject: "对象数据异常" }; unsupportedContainer: "暂不支持此对象结构", invalidObject: "对象数据异常" };
const reasons = (item?.items || (item ? [item] : [])).map(object => { const reasons = (item?.items || (item ? [item] : [])).map(object => {
@@ -278,18 +282,20 @@ export class CanvasEditor {
const layer = document.createElement("div"); const layer = document.createElement("div");
layer.className = "edit-layer"; layer.className = "edit-layer";
const artwork = new Map([...surface.querySelectorAll("[data-ofd-object], [data-ofd-child]")].map((node, order) => [node.getAttribute("data-ofd-object") || node.getAttribute("data-ofd-child"), { node, order }])); const artwork = new Map([...surface.querySelectorAll("[data-ofd-object], [data-ofd-child]")].map((node, order) => [node.getAttribute("data-ofd-object") || node.getAttribute("data-ofd-child"), { node, order }]));
const objects = [...page.objects].sort((a, b) => (artwork.get(a.id)?.order ?? 0) - (artwork.get(b.id)?.order ?? 0)); const objects = [...page.objects].sort((a, b) => Number(Boolean(b.background)) - Number(Boolean(a.background))
|| (artwork.get(a.id)?.order ?? 0) - (artwork.get(b.id)?.order ?? 0));
for (const object of objects) { for (const object of objects) {
const node = document.createElement("div"); const node = document.createElement("div");
node.className = "edit-object"; node.className = "edit-object";
node.classList.toggle("read-only", !canEditObject(object, "transform")); node.classList.toggle("edit-background", Boolean(object.background));
node.classList.toggle("read-only", !canEditObject(object, "move"));
node.title = objectEditReason(object); node.title = objectEditReason(object);
if (lineShape(object.shape)) { if (lineShape(object.shape)) {
node.classList.add("edit-line"); node.classList.add("edit-line");
} }
node.tabIndex = 0; node.tabIndex = 0;
node.setAttribute("role", "button"); node.setAttribute("role", "button");
node.setAttribute("aria-label", { ImageObject: "图片对象", TextObject: "文字对象", PathObject: "图形对象", CompositeObject: "复合对象", CompositeGraphicUnit: "复合对象" }[object.type]); node.setAttribute("aria-label", { ImageObject: "图片对象", TextObject: "文字对象", PathObject: "图形对象", CompositeObject: "复合对象", CompositeGraphicUnit: "复合对象", Annotation: "注解对象" }[object.type]);
const item = { ...object, index, page: { width: page.width, height: page.height }, node, surface }; const item = { ...object, index, page: { width: page.width, height: page.height }, node, surface };
item.artwork = artwork.get(object.id)?.node; item.artwork = artwork.get(object.id)?.node;
if (object.type === "PathObject" && (object.shape || object.outline) || (object.scoped || object.type === "CompositeObject" || object.type === "CompositeGraphicUnit") && object.contours?.length) { if (object.type === "PathObject" && (object.shape || object.outline) || (object.scoped || object.type === "CompositeObject" || object.type === "CompositeGraphicUnit") && object.contours?.length) {
@@ -527,8 +533,9 @@ export class CanvasEditor {
node = target?.node; node = target?.node;
this.focus(); this.focus();
const additive = event.shiftKey || event.ctrlKey || event.metaKey || this.multiple; const additive = event.shiftKey || event.ctrlKey || event.metaKey || this.multiple;
if (!target) { if (!target || target.background && !this.items().includes(target)) {
this.startMarquee(event, additive); this.startMarquee(event, additive);
if (this.drag?.marquee) this.drag.target = target;
return; return;
} }
if (additive && !event.target.dataset.corner) { if (additive && !event.target.dataset.corner) {
@@ -540,7 +547,7 @@ export class CanvasEditor {
return; return;
} }
const item = this.selected; const item = this.selected;
if (!canEditObject(item, "transform")) return; if (!canEditObject(item, "move")) return;
this.drag = { this.drag = {
item, pointerID: event.pointerId, corner: !item.items || target === item ? resizeCorner(node, event) : "", item, pointerID: event.pointerId, corner: !item.items || target === item ? resizeCorner(node, event) : "",
rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(), rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(),
@@ -739,6 +746,7 @@ export class CanvasEditor {
this.drag = null; this.drag = null;
drag.preview.remove(); drag.preview.remove();
this.viewer.releasePointerCapture(drag.pointerID); this.viewer.releasePointerCapture(drag.pointerID);
if (drag.target && !drag.moved) this.setSelection([...drag.base, drag.target]);
return; return;
} }
if (drag.shape) { if (drag.shape) {
@@ -810,7 +818,8 @@ export class CanvasEditor {
if (!page) return; if (!page) return;
event.preventDefault(); event.preventDefault();
this.focus(); this.focus();
this.drag = { erase: this.tool, page, surface, items: [...surface.querySelectorAll(".edit-object")].map(node => this.nodes.get(node)), this.drag = { erase: this.tool, page, surface, items: [...surface.querySelectorAll(".edit-object")].map(node => this.nodes.get(node))
.filter(item => this.tool === "erase-object" || !item.background),
erased: new Set(), shape: "rectangle", pointerID: event.pointerId, clientX: event.clientX, clientY: event.clientY, erased: new Set(), shape: "rectangle", pointerID: event.pointerId, clientX: event.clientX, clientY: event.clientY,
rect: surface.getBoundingClientRect(), rotation: this.options.rotation() }; rect: surface.getBoundingClientRect(), rotation: this.options.rotation() };
if (this.tool === "erase-region") Object.assign(this.drag, this.createPreview(surface, page, "rectangle", if (this.tool === "erase-region") Object.assign(this.drag, this.createPreview(surface, page, "rectangle",
@@ -866,9 +875,11 @@ export class CanvasEditor {
const box = { x: Math.min(from.x, to.x), y: Math.min(from.y, to.y), width: Math.abs(to.x-from.x), height: Math.abs(to.y-from.y) }; const box = { x: Math.min(from.x, to.x), y: Math.min(from.y, to.y), width: Math.abs(to.x-from.x), height: Math.abs(to.y-from.y) };
paintShape(drag.node, "rectangle", box); paintShape(drag.node, "rectangle", box);
if (Math.hypot(event.clientX-drag.clientX, event.clientY-drag.clientY) < 3) return; if (Math.hypot(event.clientX-drag.clientX, event.clientY-drag.clientY) < 3) return;
drag.moved = true;
const items = [...drag.base]; const items = [...drag.base];
for (const node of drag.surface.querySelectorAll(".edit-object")) { for (const node of drag.surface.querySelectorAll(".edit-object")) {
const item = this.nodes.get(node); const item = this.nodes.get(node);
if (item.background && (item.x < box.x || item.y < box.y || item.x + item.width > box.x + box.width || item.y + item.height > box.y + box.height)) continue;
if (!items.includes(item) && item.x <= box.x+box.width && item.x+item.width >= box.x && item.y <= box.y+box.height && item.y+item.height >= box.y) { if (!items.includes(item) && item.x <= box.x+box.width && item.x+item.width >= box.x && item.y <= box.y+box.height && item.y+item.height >= box.y) {
items.push(item); items.push(item);
} }
@@ -1030,7 +1041,7 @@ export class CanvasEditor {
} else if (edit && !this.drag && !this.selected.items) { } else if (edit && !this.drag && !this.selected.items) {
if (this.nudge) this.commitNudge().then(saved => { if (saved && this.selected) this.options.onEdit(this.selected); }); if (this.nudge) this.commitNudge().then(saved => { if (saved && this.selected) this.options.onEdit(this.selected); });
else this.options.onEdit(this.selected); else this.options.onEdit(this.selected);
} else if (!this.drag && directions[event.key] && canEditObject(this.selected, "transform")) { } else if (!this.drag && directions[event.key] && canEditObject(this.selected, "move")) {
const [x, y] = directions[event.key]; const [x, y] = directions[event.key];
const [dx, dy] = [[x, y], [y, -x], [-x, -y], [-y, x]][this.options.rotation() / 90]; const [dx, dy] = [[x, y], [y, -x], [-x, -y], [-y, x]][this.options.rotation() / 90];
const step = event.shiftKey ? 10 : 1; const step = event.shiftKey ? 10 : 1;
+26
View File
@@ -118,6 +118,7 @@ func RunWASM() {
registerCallback("ofdgoChangePage", changePage) registerCallback("ofdgoChangePage", changePage)
registerCallback("ofdgoBatchPages", batchPages) registerCallback("ofdgoBatchPages", batchPages)
registerCallback("ofdgoChangeOutline", changeOutline) registerCallback("ofdgoChangeOutline", changeOutline)
registerCallback("ofdgoChangeAnnotations", changeAnnotations)
registerCallback("ofdgoStyleObjects", styleObjects) registerCallback("ofdgoStyleObjects", styleObjects)
registerCallback("ofdgoMoveOutline", moveOutline) registerCallback("ofdgoMoveOutline", moveOutline)
registerCallback("ofdgoCaptureStyle", captureStyle) registerCallback("ofdgoCaptureStyle", captureStyle)
@@ -854,6 +855,31 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
return objects, nil return objects, nil
} }
// changeAnnotations 修改注解说明、平移或删除同页注解,复用会话历史与预览
// 入参: args 页面索引、注解标识数组、操作及参数
// 返回: any 编辑状态, error 错误信息
func changeAnnotations(args []js.Value) (any, error) {
return changeObjects(func() error {
ids := stringsFromJS(args[1])
for i := range ids {
ids[i] = strings.TrimPrefix(ids[i], "annotation:")
}
switch args[2].String() {
case "update":
if len(ids) != 1 {
return fmt.Errorf("select one annotation to update")
}
return currentEditor.UpdateAnnotation(args[0].Int(), ids[0], args[3].String(), args[4].String())
case "move":
return currentEditor.MoveAnnotations(args[0].Int(), ids, args[3].Float(), args[4].Float())
case "delete":
return currentEditor.DeleteAnnotations(args[0].Int(), ids)
default:
return fmt.Errorf("unsupported annotation action %q", args[2].String())
}
})
}
// editorCapabilities 统一顶层与内部对象的操作能力和受限原因 // editorCapabilities 统一顶层与内部对象的操作能力和受限原因
// 入参: capability 库层能力, kind 对象类型 // 入参: capability 库层能力, kind 对象类型
// 返回: map[string]any 前端能力 // 返回: map[string]any 前端能力
+308
View File
@@ -0,0 +1,308 @@
// 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 (
"bytes"
"encoding/xml"
"fmt"
"maps"
"path"
"strings"
"time"
)
// Annotations 获取指定页面的注解信息,包含不可见注解,返回值与编辑器相互独立
// 入参: index 页面索引
// 返回: []AnnotationInfo 注解信息, error 错误信息
func (e *Editor) Annotations(index int) ([]AnnotationInfo, error) {
if index < 0 || index >= len(e.pages) {
return nil, fmt.Errorf("page index %d out of range", index)
}
if e.source == nil {
return nil, nil
}
return e.source.reader.annotationInfos(index, e.pages[index].ID), nil
}
// UpdateAnnotation 修改注解说明和作者,保留外观、动作、参数及扩展字段
// 有效修改更新LastModDate并计入一次撤销记录,不受原文件ReadOnly声明限制
// 入参: index 页面索引, id 注解标识, remark 说明, creator 作者
// 返回: error 错误信息
func (e *Editor) UpdateAnnotation(index int, id, remark, creator string) error {
return e.editAnnotations(index, []string{id}, func(data []byte, node *editorXML) ([]byte, error) {
standalone, err := editorXMLStandalone(data[node.start:node.end], node)
if err != nil {
return nil, err
}
var annotation Annotation
if err := xml.Unmarshal(standalone, &annotation); err != nil {
return nil, err
}
if annotation.Remark == remark && annotation.Creator == creator {
return data[node.start:node.end], nil
}
data = standalone
root, err := parseEditorXML(data)
if err != nil {
return nil, err
}
if annotation.Remark != remark {
var escaped bytes.Buffer
if err := xml.EscapeText(&escaped, []byte(remark)); err != nil {
return nil, err
}
if child := root.child("Remark"); child != nil {
data = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, child, escaped.Bytes())})
} else if root.open == root.end {
data = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, root, editorXMLText("Remark", remark))})
} else {
data = editorPatchXML(data, []editorXMLPatch{{root.open, root.open, editorXMLText("Remark", remark)}})
}
}
root, err = parseEditorXML(data)
if err != nil {
return nil, err
}
if annotation.Creator != creator {
data, err = editorXMLAttribute(data, root, "Creator", creator)
if err != nil {
return nil, err
}
}
return editorAnnotationDate(data)
})
}
// MoveAnnotations 平移同页注解,保留内部图元、动作坐标及原始尺寸,一次操作计入一条撤销记录
// 入参: index 页面索引, ids 不重复的注解标识, dx 横向毫米偏移, dy 纵向毫米偏移
// 返回: error 错误信息
func (e *Editor) MoveAnnotations(index int, ids []string, dx, dy float64) error {
if !finite(dx) || !finite(dy) {
return fmt.Errorf("annotation offset must be finite")
}
return e.editAnnotations(index, ids, func(data []byte, node *editorXML) ([]byte, error) {
appearance := node.child("Appearance")
if appearance == nil {
return nil, fmt.Errorf("annotation %q has no appearance", node.attr("ID"))
}
box, err := ParseBox(appearance.attr("Boundary"))
if err != nil || !finite(box.X+dx) || !finite(box.Y+dy) || !finite(box.W) || !finite(box.H) || box.W < 0 || box.H < 0 {
return nil, fmt.Errorf("annotation %q has invalid boundary", node.attr("ID"))
}
if dx == 0 && dy == 0 {
return data[node.start:node.end], nil
}
data, err = editorXMLStandalone(data[node.start:node.end], node)
if err != nil {
return nil, err
}
root, err := parseEditorXML(data)
if err != nil {
return nil, err
}
appearance = root.child("Appearance")
boundary := fmt.Sprintf("%s %s %s %s", ofdNumber(box.X+dx), ofdNumber(box.Y+dy), ofdNumber(box.W), ofdNumber(box.H))
updated, err := editorXMLAttribute(data, appearance, "Boundary", boundary)
if err != nil {
return nil, err
}
return editorAnnotationDate(editorPatchXML(data, []editorXMLPatch{{appearance.start, appearance.end, updated}}))
})
}
// DeleteAnnotations 删除同页注解并清理空的页面注解引用,一次操作计入一条撤销记录
// 入参: index 页面索引, ids 不重复的注解标识
// 返回: error 错误信息
func (e *Editor) DeleteAnnotations(index int, ids []string) error {
return e.editAnnotations(index, ids, func([]byte, *editorXML) ([]byte, error) { return nil, nil })
}
// editorAnnotationDate 更新注解修改日期,采用标准xs:date格式
// 入参: data 注解XML片段
// 返回: []byte 新片段, error 错误信息
func editorAnnotationDate(data []byte) ([]byte, error) {
root, err := parseEditorXML(data)
if err != nil {
return nil, err
}
return editorXMLAttribute(data, root, "LastModDate", time.Now().Format("2006-01-02"))
}
// editAnnotations 原子修改页面注解原文,按需分离共用文件并保留输入包与历史快照
// 入参: index 页面索引, ids 注解标识, edit 单个注解修改函数
// 返回: error 错误信息
func (e *Editor) editAnnotations(index int, ids []string, edit func([]byte, *editorXML) ([]byte, error)) error {
if index < 0 || index >= len(e.pages) {
return fmt.Errorf("page index %d out of range", index)
}
wanted := make(map[string]bool, len(ids))
for _, id := range ids {
if id == "" || wanted[id] {
return fmt.Errorf("missing or duplicate annotation ID %q", id)
}
wanted[id] = true
}
if len(wanted) == 0 {
return nil
}
if e.source == nil || e.source.document.Annotations == "" {
return fmt.Errorf("page has no annotations")
}
reader := e.source.reader
name, err := editorPageLocation(reader, nil, "", "/"+reader.ResPath(e.source.document.Annotations))
if err != nil {
return err
}
data, err := reader.readFile(name)
if err != nil {
return err
}
root, err := parseEditorXML(data)
if err != nil {
return err
}
parts := make(map[string][]byte)
var patches []editorXMLPatch
found := make(map[string]bool, len(ids))
remaining := 0
for _, ref := range root.children {
if ref.name.Local != "Page" || ref.name.Space != root.name.Space {
continue
}
remaining++
if ref.attr("PageID") != e.pages[index].ID {
continue
}
loc := ref.child("FileLoc")
if loc == nil {
return fmt.Errorf("annotation page has no FileLoc")
}
file, err := editorPageLocation(reader, nil, name, strings.TrimSpace(editorImportText(data, loc)))
if err != nil {
return err
}
content, err := reader.readFile(file)
if err != nil {
return err
}
page, err := parseEditorXML(content)
if err != nil {
return err
}
var changes []editorXMLPatch
count := 0
for _, node := range page.children {
if node.name.Local != "Annot" || node.name.Space != page.name.Space {
continue
}
count++
id := node.attr("ID")
if !wanted[id] {
continue
}
if found[id] {
return fmt.Errorf("ambiguous annotation ID %q", id)
}
found[id] = true
value, err := edit(content, node)
if err != nil {
return err
}
if value == nil {
count--
}
if !bytes.Equal(value, content[node.start:node.end]) {
changes = append(changes, editorXMLPatch{node.start, node.end, value})
}
}
if len(changes) == 0 {
continue
}
sharedFile := false
for _, other := range root.children {
if other == ref || other.name.Local != "Page" || other.name.Space != root.name.Space || other.child("FileLoc") == nil {
continue
}
shared, err := editorPageLocation(reader, nil, name, strings.TrimSpace(editorImportText(data, other.child("FileLoc"))))
if err == nil && shared == file {
sharedFile = true
break
}
}
if count == 0 {
patches = append(patches, editorXMLPatch{ref.start, ref.end, nil})
remaining--
if !sharedFile {
parts[file] = editorPatchXML(content, changes)
}
continue
}
if sharedFile {
file = path.Join(e.source.directory, "Annotations", e.pages[index].ID, file)
var escaped bytes.Buffer
_ = xml.EscapeText(&escaped, []byte("/"+file))
patches = append(patches, editorXMLContent(data, loc, escaped.Bytes()))
}
parts[file] = editorPatchXML(content, changes)
}
for _, id := range ids {
if !found[id] {
return fmt.Errorf("annotation %q not found", id)
}
}
if len(patches) != 0 {
parts[name] = editorPatchXML(data, patches)
if remaining == 0 {
docName, err := editorPageLocation(reader, nil, "", "/"+cleanPackagePath(reader.OFD.DocBody[0].DocRoot))
if err != nil {
return err
}
docData, err := reader.readFile(docName)
if err != nil {
return err
}
doc, err := parseEditorXML(docData)
if err != nil {
return err
}
parts[docName] = editorXMLSetText(docData, doc, [][2]string{{"Annotations", ""}})
}
}
if len(parts) == 0 {
return nil
}
files := maps.Clone(reader.files)
if files == nil {
files = make(map[string][]byte)
}
maps.Copy(files, parts)
updated := &Reader{Zip: reader.Zip, files: files}
if err := updated.initRoot(); err != nil {
return err
}
doc, err := updated.Doc()
if err != nil {
return err
}
before, after := e.source, *e.source
after.reader, after.document = updated, doc
e.source = &after
if change := e.recordChange(); change != nil {
change.undo = func(e *Editor) { e.source = before }
change.redo = func(e *Editor) { e.source = &after }
}
return nil
}
+2 -17
View File
@@ -158,7 +158,7 @@ func (e *Editor) UpdateOutline(path []int, title string, page int) error {
if err != nil { if err != nil {
return err return err
} }
value, err := editorOutlineAttribute(updated, node, "Title", title) value, err := editorXMLAttribute(updated, node, "Title", title)
if err != nil { if err != nil {
return err return err
} }
@@ -326,21 +326,6 @@ func editorOutlineNode(root *editorXML, path []int) (*editorXML, error) {
return root, nil return root, nil
} }
// editorOutlineAttribute 修改单个标准属性,保留节点其余原文
// 入参: data 原文, node 节点, name 属性名, value 新值
// 返回: []byte 修改后的节点, error 错误信息
func editorOutlineAttribute(data []byte, node *editorXML, name, value string) ([]byte, error) {
before, err := editorXMLContainer(node.name.Local, ofdAttrs{{Name: xml.Name{Local: name}, Value: node.attr(name)}}, nil)
if err != nil {
return nil, err
}
after, err := editorXMLContainer(node.name.Local, ofdAttrs{{Name: xml.Name{Local: name}, Value: value}}, nil)
if err != nil {
return nil, err
}
return editorXMLMerge(data, node, before, after)
}
// setOutlineXML 更新目录及可选叶节点计数,保存一次撤销记录 // setOutlineXML 更新目录及可选叶节点计数,保存一次撤销记录
// 入参: data 新目录原文 // 入参: data 新目录原文
// 返回: error 错误信息 // 返回: error 错误信息
@@ -369,7 +354,7 @@ func (e *Editor) setOutlineXML(data []byte) error {
for _, child := range editorOutlineChildren(node) { for _, child := range editorOutlineChildren(node) {
count += leaves(child) count += leaves(child)
} }
value, err := editorOutlineAttribute(data, node, "Count", strconv.Itoa(count)) value, err := editorXMLAttribute(data, node, "Count", strconv.Itoa(count))
if err != nil { if err != nil {
return err return err
} }
+1 -1
View File
@@ -39,7 +39,7 @@ type editorResourceRefs struct {
// 入参: parts 已修改和新增的包内条目, progress 保存进度回调 // 入参: parts 已修改和新增的包内条目, progress 保存进度回调
// 返回: map[string]bool 可移除的二进制条目, error 读取错误 // 返回: map[string]bool 可移除的二进制条目, error 读取错误
func (e *Editor) compactSourceResources(parts map[string][]byte, progress editorProgress) (map[string]bool, error) { func (e *Editor) compactSourceResources(parts map[string][]byte, progress editorProgress) (map[string]bool, error) {
if len(parts) == 0 { if len(parts) == 0 && e.revision == 0 {
return nil, nil return nil, nil
} }
reader := e.source.reader reader := e.source.reader
+15
View File
@@ -546,6 +546,21 @@ func editorXMLEncodedFragment(data []byte, node *editorXML) []byte {
return editorPatchXML(fragment, []editorXMLPatch{{position, position, []byte(" xmlns:ofd=\"" + ofdNamespace + "\"")}}) return editorPatchXML(fragment, []editorXMLPatch{{position, position, []byte(" xmlns:ofd=\"" + ofdNamespace + "\"")}})
} }
// editorXMLAttribute 修改单个标准属性,保留节点其余原文
// 入参: data 原文, node 节点, name 属性名, value 新值
// 返回: []byte 修改后的节点, error 错误信息
func editorXMLAttribute(data []byte, node *editorXML, name, value string) ([]byte, error) {
before, err := editorXMLContainer(node.name.Local, ofdAttrs{{Name: xml.Name{Local: name}, Value: node.attr(name)}}, nil)
if err != nil {
return nil, err
}
after, err := editorXMLContainer(node.name.Local, ofdAttrs{{Name: xml.Name{Local: name}, Value: value}}, nil)
if err != nil {
return nil, err
}
return editorXMLMerge(data, node, before, after)
}
// editorXMLContainer 用标准编码器封装已编码的子节点 // editorXMLContainer 用标准编码器封装已编码的子节点
// 入参: name 节点名称, attrs 属性, content 子节点XML // 入参: name 节点名称, attrs 属性, content 子节点XML
// 返回: []byte XML片段, error 错误信息 // 返回: []byte XML片段, error 错误信息
+8
View File
@@ -23,11 +23,19 @@ func (r *Renderer) renderAnnotations(ctx *canvas.Context, pageID string, pageH f
if annot.Visible != nil && !*annot.Visible { if annot.Visible != nil && !*annot.Visible {
continue continue
} }
groups, grouped := ctx.Renderer.(interface {
beginAnnotation(string) bool
endObject()
})
grouped = grouped && groups.beginAnnotation(annot.ID)
box, _ := ParseBox(annot.Appearance.Boundary) box, _ := ParseBox(annot.Appearance.Boundary)
ctm := Matrix{a: 1, d: 1, e: box.X, f: box.Y} ctm := Matrix{a: 1, d: 1, e: box.X, f: box.Y}
for i := range annot.Appearance.Objects { for i := range annot.Appearance.Objects {
r.renderObject(ctx, &annot.Appearance.Objects[i], pageH, nil, &ctm, false, nil) r.renderObject(ctx, &annot.Appearance.Objects[i], pageH, nil, &ctm, false, nil)
} }
if grouped {
groups.endObject()
}
} }
} }
+14 -2
View File
@@ -88,7 +88,7 @@ func (r *Renderer) RenderToSVGWithResources(page *PageContent, writer io.Writer)
return r.renderSVGResources(page, writer, true, false) return r.renderSVGResources(page, writer, true, false)
} }
// RenderToSVGWithObjects 渲染SVG并为页面直接对象添加data-ofd-object分组,保持原绘制顺序和分离资源 // RenderToSVGWithObjects 渲染SVG并为页面对象和注解添加data-ofd-object分组,注解标识使用annotation:前缀
// 入参: page 页面内容, writer 输出流 // 入参: page 页面内容, writer 输出流
// 返回: SVGResources 字体和图片资源, error 错误信息 // 返回: SVGResources 字体和图片资源, error 错误信息
func (r *Renderer) RenderToSVGWithObjects(page *PageContent, writer io.Writer) (SVGResources, error) { func (r *Renderer) RenderToSVGWithObjects(page *PageContent, writer io.Writer) (SVGResources, error) {
@@ -143,7 +143,7 @@ func (r *Renderer) renderSVGResources(page *PageContent, writer io.Writer, image
return SVGResources{Fonts: s.fonts, Images: s.images}, buffer.Flush() return SVGResources{Fonts: s.fonts, Images: s.images}, buffer.Flush()
} }
// beginObject 标记直接对象及复合成员,模板与注释不单独标记 // beginObject 标记直接对象及复合成员,模板与注解内部图元不单独标记
// 入参: object 图形对象 // 入参: object 图形对象
// 返回: bool 是否写入分组 // 返回: bool 是否写入分组
func (s *svgResourceRenderer) beginObject(object *GraphicObject) bool { func (s *svgResourceRenderer) beginObject(object *GraphicObject) bool {
@@ -169,6 +169,18 @@ func (s *svgResourceRenderer) endObject() {
fmt.Fprint(s.writer, `</g>`) fmt.Fprint(s.writer, `</g>`)
} }
// beginAnnotation 标记完整注解外观,内部图元保持原有层级和绘制顺序
// 入参: id 注解标识
// 返回: bool 是否写入分组
func (s *svgResourceRenderer) beginAnnotation(id string) bool {
if s.objects == nil {
return false
}
s.objectStack = append(s.objectStack, svgObjectGroup{})
fmt.Fprintf(s.writer, `<g data-ofd-object="annotation:%s">`, html.EscapeString(id))
return true
}
// skipObjects 为不可见资源保留内部成员序号 // skipObjects 为不可见资源保留内部成员序号
// 入参: count 直接成员数量 // 入参: count 直接成员数量
func (s *svgResourceRenderer) skipObjects(count int) { func (s *svgResourceRenderer) skipObjects(count int) {