feat(编辑能力): 新增画笔和注解创建,完善水印、印章与链接编辑,优化选取和保存体验
build.yaml / build (push) Successful in 39s

This commit is contained in:
xiaoqidun committed 2026-09-18 08:34:35 +08:00
1 parent ed11ade4e8
commit a8346a6006
23 files changed
+2279 -86

No files matched your search

+33
View File
@@ -99,6 +99,8 @@
<button id="drawRectangleButton" class="button" type="button" title="矩形" aria-label="矩形" aria-pressed="false">矩形</button> <button id="drawRectangleButton" class="button" type="button" title="矩形" aria-label="矩形" aria-pressed="false">矩形</button>
<button id="drawEllipseButton" class="button" type="button" title="椭圆" aria-label="椭圆" aria-pressed="false">椭圆</button> <button id="drawEllipseButton" class="button" type="button" title="椭圆" aria-label="椭圆" aria-pressed="false">椭圆</button>
<select id="arrowTool" aria-label="箭头" title="箭头"><option value="" hidden>箭头</option><option value="line">直线</option><option value="arrow">单向</option><option value="double-arrow">双向</option></select> <select id="arrowTool" aria-label="箭头" title="箭头"><option value="" hidden>箭头</option><option value="line">直线</option><option value="arrow">单向</option><option value="double-arrow">双向</option></select>
<select id="penTool" aria-label="画笔" title="画笔"><option value="" hidden>画笔</option><option value="pen-round">圆笔</option><option value="pen-pressure">钢笔</option></select>
<select id="annotationTool" aria-label="注解工具" title="注解工具"><option value="" hidden>注解</option><option value="note">批注</option><option value="watermark">水印</option><option value="stamp">印章</option><option value="link">链接</option><option value="details">说明</option><option value="replace">替换</option><option value="objects">选取</option></select>
<span>插入</span> <span>插入</span>
</div> </div>
<div class="editor-group"> <div class="editor-group">
@@ -264,6 +266,37 @@
<div class="form-actions"><div id="infoStatus" class="form-status" role="status"></div><button id="infoCancel" class="button" type="button">取消</button><button class="button" type="submit">确定</button></div> <div class="form-actions"><div id="infoStatus" class="form-status" role="status"></div><button id="infoCancel" class="button" type="button">取消</button><button class="button" type="submit">确定</button></div>
</form> </form>
</dialog> </dialog>
<dialog id="annotationCreate" class="form-dialog" aria-label="添加注解">
<form id="annotationCreateForm">
<label class="form-row" data-annotation="watermark"><span>类型</span><select id="annotationContent"><option value="text">文字</option><option value="image">图片</option></select></label>
<label class="form-row" data-annotation="replace"><span>原文</span><select id="annotationOld"></select></label>
<label class="form-row" data-annotation="replace"><span>字体</span><select id="annotationFont"><option value="keep">原字体</option><option value="current">当前字体</option></select></label>
<label class="form-row" id="annotationTextRow"><span>内容</span><textarea id="annotationText" rows="2" required></textarea></label>
<label class="form-row" id="annotationImageRow" hidden><span>图片</span><input id="annotationImage" type="file" accept="image/png,image/jpeg,image/bmp,image/tiff,image/gif,image/webp"></label>
<label class="form-row" data-annotation="note watermark stamp link"><span>作者</span><input id="annotationAuthor" type="text"></label>
<label class="form-row" data-annotation="link"><span>目标</span><select id="annotationLinkKind"><option value="uri">网址</option><option value="page">页面</option><option value="keep" hidden>原链接</option></select></label>
<label class="form-row" data-annotation="link" id="annotationAddressRow"><span>网址</span><input id="annotationAddress" type="text"></label>
<label class="form-row" data-annotation="link" id="annotationTargetRow"><span>页码</span><input id="annotationTarget" type="number" min="1" value="1"></label>
<div class="form-pair" data-annotation="note watermark stamp link">
<label class="form-row"><span>横向</span><span class="form-unit"><input id="annotationX" type="number" step="any" value="10" required><span>mm</span></span></label>
<label class="form-row"><span>纵向</span><span class="form-unit"><input id="annotationY" type="number" step="any" value="10" required><span>mm</span></span></label>
</div>
<div class="form-pair" data-annotation="watermark stamp link">
<label class="form-row"><span>宽度</span><span class="form-unit"><input id="annotationWidth" type="number" min="1" step="any" value="60" required><span>mm</span></span></label>
<label class="form-row"><span>高度</span><span class="form-unit"><input id="annotationHeight" type="number" min="1" step="any" value="12" required><span>mm</span></span></label>
</div>
<div class="form-pair" data-annotation="watermark stamp">
<label class="form-row"><span>角度</span><span class="form-unit"><input id="annotationAngle" type="number" min="-180" max="180" step="any" value="-30" required><span>°</span></span></label>
<label class="form-row"><span>透明</span><span class="form-unit"><input id="annotationOpacity" type="number" min="0" max="100" step="any" value="80" aria-label="透明度" required><span>%</span></span></label>
</div>
<label class="form-row" data-annotation="watermark"><span>排列</span><select id="annotationTile"><option value="single">单个</option><option value="tile">平铺</option></select></label>
<label class="form-row" data-annotation="note watermark stamp link"><span>页面</span><input id="annotationPages" type="text" required></label>
<div class="form-actions"><div id="annotationCreateStatus" class="form-status" role="status"></div><button id="annotationCreateCancel" class="button" type="button">取消</button><button class="button" type="submit">确定</button></div>
</form>
</dialog>
<dialog id="objectPicker" class="form-dialog" aria-label="选取对象">
<form method="dialog"><div id="objectPickerList" class="object-picker-list"></div><div class="form-actions"><button class="button" type="submit">关闭</button></div></form>
</dialog>
<dialog id="annotationNote" class="form-dialog" aria-label="注解"> <dialog id="annotationNote" class="form-dialog" aria-label="注解">
<form id="annotationForm" method="dialog"> <form id="annotationForm" method="dialog">
<div id="annotationDetail" class="annotation-detail"></div> <div id="annotationDetail" class="annotation-detail"></div>
+21 -7
View File
@@ -134,6 +134,24 @@ a:focus-visible {
grid-template-columns: repeat(4, max-content); grid-template-columns: repeat(4, max-content);
} }
.editor-group:has(#penTool) {
grid-template-columns: repeat(4, max-content);
}
.object-picker-list {
display: grid;
gap: 4px;
max-height: 55vh;
overflow-y: auto;
}
.object-picker-list .button {
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.editor-field { .editor-field {
display: grid; display: grid;
align-items: center; align-items: center;
@@ -1516,10 +1534,6 @@ 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 {
@@ -2310,16 +2324,16 @@ button.outline-link:hover {
@media (min-width: 901px) and (max-width: 1600px) and (pointer: fine) { @media (min-width: 901px) and (max-width: 1600px) and (pointer: fine) {
.editor-group { .editor-group {
padding-inline: 5px; padding-inline: 3px;
column-gap: 3px; column-gap: 3px;
} }
.editor-toolbar .button { .editor-toolbar .button {
padding-inline: 7px; padding-inline: 6px;
} }
.editor-group:has(#textFont) { .editor-group:has(#textFont) {
grid-template-columns: 144px 32px; grid-template-columns: 136px 32px;
} }
.editor-paragraph { .editor-paragraph {
+197 -32
View File
@@ -28,6 +28,7 @@ let textMeasure = null;
const state = { const state = {
composite: null, composite: null,
annotationEdit: null, annotationEdit: null,
annotationCreate: null,
ready: false, ready: false,
wasmExited: false, wasmExited: false,
wasmSeq: 0, wasmSeq: 0,
@@ -335,6 +336,12 @@ 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"),
annotationTool: document.querySelector("#annotationTool"),
penTool: document.querySelector("#penTool"),
annotationCreate: document.querySelector("#annotationCreate"),
annotationCreateStatus: document.querySelector("#annotationCreateStatus"),
objectPicker: document.querySelector("#objectPicker"),
objectPickerList: document.querySelector("#objectPickerList"),
annotationForm: document.querySelector("#annotationForm"), annotationForm: document.querySelector("#annotationForm"),
annotationFields: document.querySelector("#annotationFields"), annotationFields: document.querySelector("#annotationFields"),
annotationRemark: document.querySelector("#annotationRemark"), annotationRemark: document.querySelector("#annotationRemark"),
@@ -356,6 +363,7 @@ const fontManager = new FontManager({
onPermissionChange: updateFontPermissionHint, onPermissionChange: updateFontPermissionHint,
}); });
const fontPicker = new FontPicker(el.textFont, el.textFontToggle, el.textFontList, changeTextFont, loadEditorFonts); const fontPicker = new FontPicker(el.textFont, el.textFontToggle, el.textFontList, changeTextFont, loadEditorFonts);
const annotationFields = Object.fromEntries(["CreateForm", "CreateCancel", "Content", "Old", "Font", "Text", "TextRow", "Image", "ImageRow", "Author", "LinkKind", "Address", "AddressRow", "Target", "TargetRow", "X", "Y", "Width", "Height", "Angle", "Opacity", "Tile", "Pages"].map(key => [key, document.querySelector(`#annotation${key}`)]));
el.editorTools.addEventListener("scroll", () => fontPicker.position()); el.editorTools.addEventListener("scroll", () => fontPicker.position());
const canvasEditor = new CanvasEditor(el.viewerPanel, { const canvasEditor = new CanvasEditor(el.viewerPanel, {
@@ -371,9 +379,7 @@ 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) => (item.items || [item]).every(object => object.type === "Annotation") onTransform: (item, change) => changeDocument(item.items ? "ofdgoTransformObjects" : "ofdgoTransformObject", item,
? 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();
@@ -389,13 +395,8 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
syncSelection(); syncSelection();
if (state.fontRenderPending) window.setTimeout(refreshPendingFonts, 0); if (state.fontRenderPending) window.setTimeout(refreshPendingFonts, 0);
}, },
onDelete: (item) => (item.items || [item]).every(object => object.type === "Annotation") onDelete: (item) => changeDocument(item.items ? "ofdgoDeleteObjects" : "ofdgoDeleteObject", item),
? 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) };
@@ -404,6 +405,7 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
: changeDocument("ofdgoDeleteObjects", item); : changeDocument("ofdgoDeleteObjects", item);
}, },
onEdit: editCanvasObject, onEdit: editCanvasObject,
onPick: openObjectPicker,
onExitScope: () => { onExitScope: () => {
if (!state.composite) return false; if (!state.composite) return false;
exitCompositeScope(); exitCompositeScope();
@@ -414,6 +416,8 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
onDraw: (index, shape, box, style) => changeDocument("ofdgoInsertShape", null, index, shape, onDraw: (index, shape, box, style) => changeDocument("ofdgoInsertShape", null, index, shape,
box.x, box.y, box.width, box.height, style.fill, style.fillColor, style.stroke, style.strokeColor, style.lineWidth), box.x, box.y, box.width, box.height, style.fill, style.fillColor, style.stroke, style.strokeColor, style.lineWidth),
onDrawText: beginCanvasText, onDrawText: beginCanvasText,
onDrawAnnotation: (index, kind, box) => openAnnotationCreate(kind, index, box),
onDrawInk: (index, points, style, pressure) => changeDocument("ofdgoInsertInk", null, index, JSON.stringify(points), style.lineWidth, pressure, style.strokeColor),
onCommitText: (item, value, fontData, color = null) => item.draft onCommitText: (item, value, fontData, color = null) => item.draft
? changeDocument("ofdgoInsertText", null, item.index, value, fontData || item.fontData, item.x, item.y, item.size, item.color, item.width, item.wrap, item.align, item.paragraphHeight, item.letterSpacing, ...textIndents(item)) ? changeDocument("ofdgoInsertText", null, item.index, value, fontData || item.fontData, item.x, item.y, item.size, item.color, item.width, item.wrap, item.align, item.paragraphHeight, item.letterSpacing, ...textIndents(item))
: changeDocument("ofdgoUpdateText", item, value, fontData || null, item.size, color), : changeDocument("ofdgoUpdateText", item, value, fontData || null, item.size, color),
@@ -497,6 +501,86 @@ el.infoForm.addEventListener("submit", async event => {
}); });
el.annotationClose.addEventListener("click", () => el.annotationNote.close()); el.annotationClose.addEventListener("click", () => el.annotationNote.close());
el.annotationNote.addEventListener("close", () => { state.annotationEdit = null; }); el.annotationNote.addEventListener("close", () => { state.annotationEdit = null; });
el.penTool.addEventListener("change", () => {
const tool = el.penTool.value;
el.penTool.value = "";
canvasEditor.select(null);
toggleDrawingTool(tool);
});
el.annotationTool.addEventListener("change", async () => {
const kind = el.annotationTool.value;
el.annotationTool.value = "";
if (kind === "objects") { openObjectPicker(); return; }
let selected = canvasEditor.selected;
if (state.composite?.key.startsWith("annotation:")) {
const id = state.composite.key.split("/")[0];
selected = { ...state.pageCache.get(state.composite.index)?.objects.find(item => item.id === id), index: state.composite.index, id, type: "Annotation" };
}
if (kind === "details" || kind === "replace") {
if (selected?.type !== "Annotation") { setStatus("请先选择注解"); return; }
try {
const info = await callWASM("ofdgoReadAnnotation", selected.index, selected.id);
if (kind === "details") editAnnotationDetails({ ...selected, ...info });
else openAnnotationCreate(kind, selected.index, null, selected, info);
} catch (error) { showError(error, false); }
return;
}
if (kind === "link" && selected?.annotationType === "Link") {
try {
const info = await callWASM("ofdgoReadAnnotation", selected.index, selected.id);
openAnnotationCreate(kind, selected.index, selected, selected, info);
} catch (error) { showError(error, false); }
} else {
resetCompositeScope();
updateControls();
if (kind === "watermark") openAnnotationCreate(kind, state.pageIndex);
else {
canvasEditor.select(null);
toggleDrawingTool(`annotation:${kind}`);
}
}
});
annotationFields.CreateCancel.addEventListener("click", () => el.annotationCreate.close());
el.annotationCreate.addEventListener("close", () => { state.annotationCreate = null; });
annotationFields.Content.addEventListener("change", updateAnnotationFields);
annotationFields.Height.addEventListener("input", () => { state.annotationCreate.height = annotationFields.Height.value; });
annotationFields.LinkKind.addEventListener("change", updateAnnotationFields);
annotationFields.Old.addEventListener("change", () => { annotationFields.Text.value = annotationFields.Old.value; });
annotationFields.Tile.addEventListener("change", () => {
if (annotationFields.Tile.value === "tile") {
annotationFields.X.value = 10;
annotationFields.Y.value = 10;
}
});
annotationFields.CreateForm.addEventListener("submit", async event => {
event.preventDefault();
const entry = state.annotationCreate;
if (!entry || !annotationFields.CreateForm.reportValidity() || document.body.hasAttribute("aria-busy")) return;
const openSeq = state.openSeq;
const value = key => annotationFields[key].value;
const image = entry.kind === "stamp" || entry.kind === "watermark" && value("Content") === "image";
let font = null, data = null;
try {
if (image) data = new Uint8Array(await annotationFields.Image.files[0].arrayBuffer());
if (entry.kind === "watermark" && !image || entry.kind === "replace" && value("Font") === "current") {
const choice = state.textDefaults.fontChoice || state.textFonts[Number(fontPicker.value)];
if (!choice || choice.disabled) throw new Error("请先选择可用字体");
font = await readTextFont(choice);
}
if (openSeq !== state.openSeq || state.annotationCreate !== entry) return;
const options = { kind: entry.kind, id: entry.item?.id.replace("annotation:", "") || "", text: value("Text"), old: value("Old"),
creator: value("Author"), pages: value("Pages"), uri: value("Address"), target: value("LinkKind") === "page" ? Number(value("Target")) : 0,
x: Number(value("X")), y: Number(value("Y")), width: entry.kind === "note" ? 5 : Number(value("Width")), height: entry.kind === "note" ? 5 : Number(value("Height")),
angle: Number(value("Angle")), alpha: Math.round((100 - Number(value("Opacity"))) * 255 / 100), tile: value("Tile") === "tile",
size: state.textDefaults.size, color: state.textDefaults.color };
if (entry.kind === "link" && entry.item) {
options.base = entry.info?.linkBase || "";
options.keep = value("LinkKind") === "keep" || value("LinkKind") === entry.info?.linkKind
&& (value("LinkKind") === "uri" ? value("Address") === entry.info.linkURI : Number(value("Target")) === entry.info.linkPage);
}
if (await changeDocument("ofdgoWriteAnnotation", null, entry.index, JSON.stringify(options), font, data)) el.annotationCreate.close();
} catch (error) { el.annotationCreateStatus.textContent = error.message; }
});
el.annotationForm.addEventListener("submit", async event => { el.annotationForm.addEventListener("submit", async event => {
if (!state.annotationEdit) return; if (!state.annotationEdit) return;
event.preventDefault(); event.preventDefault();
@@ -1021,7 +1105,7 @@ function handleKeyDown(event) {
function formDialogOpen() { function formDialogOpen() {
return el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open return el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open
|| el.batchPagesPanel.open || el.objectStylePanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open; || el.batchPagesPanel.open || el.objectStylePanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open || el.annotationCreate.open || el.objectPicker.open;
} }
function setObjectDimension(input, value) { function setObjectDimension(input, value) {
@@ -1053,6 +1137,7 @@ function openObjectBounds() {
} }
function canCopyStyle(item) { function canCopyStyle(item) {
if (item?.type === "Annotation") return false;
if (!item?.scoped) return canEditObject(item, "update"); if (!item?.scoped) return canEditObject(item, "update");
if (item.type === "TextObject") return canEditObject(item, "replaceFont") && canEditObject(item, "paint"); if (item.type === "TextObject") return canEditObject(item, "replaceFont") && canEditObject(item, "paint");
if (item.type === "PathObject") return canEditObject(item, "transform") && canEditObject(item, "paint"); if (item.type === "PathObject") return canEditObject(item, "transform") && canEditObject(item, "paint");
@@ -1068,7 +1153,7 @@ function openObjectStyle() {
el.copyStyleButton.disabled = items.length !== 1 || !canCopyStyle(items[0]); el.copyStyleButton.disabled = items.length !== 1 || !canCopyStyle(items[0]);
el.pasteStyleButton.disabled = !state.styleClipboard || !items.every(item => item.type === state.styleClipboard && canCopyStyle(item)); el.pasteStyleButton.disabled = !state.styleClipboard || !items.every(item => item.type === state.styleClipboard && canCopyStyle(item));
const shared = (key, fallback) => items.every(item => (item[key] ?? fallback) === (items[0][key] ?? fallback)) ? items[0][key] ?? fallback : null; const shared = (key, fallback) => items.every(item => (item[key] ?? fallback) === (items[0][key] ?? fallback)) ? items[0][key] ?? fallback : null;
const alpha = shared("alpha", 255), dash = shared("dashPattern", ""); const alpha = items.some(item => item.alphaMixed) ? null : shared("alpha", 255), dash = shared("dashPattern", "");
el.objectOpacity.value = alpha === null ? "" : String(Math.round((1 - alpha / 255) * 10000) / 100); el.objectOpacity.value = alpha === null ? "" : String(Math.round((1 - alpha / 255) * 10000) / 100);
el.objectOpacity.placeholder = alpha === null ? "混合" : ""; el.objectOpacity.placeholder = alpha === null ? "混合" : "";
el.objectStrokeFields.hidden = !items.every(item => item.type === "PathObject" && (!item.scoped || canEditObject(item, "paint"))); el.objectStrokeFields.hidden = !items.every(item => item.type === "PathObject" && (!item.scoped || canEditObject(item, "paint")));
@@ -1393,9 +1478,7 @@ async function createDocument(event) {
} }
} }
async function editCanvasObject(item) { function editAnnotationDetails(item) {
if (!state.editing || document.body.hasAttribute("aria-busy") || canvasEditor.input || item.items) return;
if (item.type === "Annotation") {
state.annotationEdit = item; state.annotationEdit = item;
el.annotationRemark.value = item.remark || ""; el.annotationRemark.value = item.remark || "";
el.annotationCreator.value = item.creator || ""; el.annotationCreator.value = item.creator || "";
@@ -1405,9 +1488,86 @@ async function editCanvasObject(item) {
el.annotationClose.textContent = "取消"; el.annotationClose.textContent = "取消";
el.annotationNote.showModal(); el.annotationNote.showModal();
el.annotationRemark.focus(); el.annotationRemark.focus();
}
function openObjectPicker(items = canvasEditor.pageItems(state.pageIndex)) {
el.objectPickerList.replaceChildren();
const labels = { TextObject: "文字", PathObject: "图形", ImageObject: "图片", CompositeObject: "组合", CompositeGraphicUnit: "组合", Annotation: "注解" };
const kinds = { Path: "批注", Stamp: "印章", Watermark: "水印", Link: "链接", Highlight: "高亮" };
for (const item of [...items].reverse()) {
const button = document.createElement("button");
button.className = "button";
button.type = "button";
button.textContent = `${kinds[item.annotationType] || labels[item.type] || "对象"} · ${item.text || item.remark || item.id.replace("annotation:", "")}`;
button.title = button.textContent;
button.addEventListener("click", () => {
el.objectPicker.close();
canvasEditor.select(item);
canvasEditor.focus();
});
el.objectPickerList.append(button);
}
if (!items.length) { setStatus("页面暂无对象"); return; }
el.objectPicker.showModal();
}
function openAnnotationCreate(kind, index, box = null, item = null, info = null) {
const page = state.doc.pages[index];
const width = Math.min(60, page.width), height = 12;
state.annotationCreate = { kind, index, item, info, height: box?.height || height };
annotationFields.CreateForm.reset();
annotationFields.Text.value = kind === "watermark" ? "水印" : "";
annotationFields.Pages.value = String(index + 1);
annotationFields.Target.max = state.doc.pageCount;
annotationFields.Target.value = String(index + 1);
annotationFields.LinkKind.querySelector('[value="keep"]').hidden = !item;
if (kind === "link" && info) {
annotationFields.LinkKind.value = info.linkKind;
annotationFields.Address.value = info.linkURI || "";
if (info.linkPage) annotationFields.Target.value = String(info.linkPage);
}
annotationFields.X.value = box?.x ?? (page.width - width) / 2;
annotationFields.Y.value = box?.y ?? (page.height - height) / 2;
annotationFields.Width.value = box?.width || width;
annotationFields.Angle.value = kind === "watermark" ? -30 : 0;
annotationFields.Opacity.value = kind === "watermark" ? 80 : 0;
annotationFields.Old.replaceChildren(...(info?.texts || []).map(text => new Option(text, text)));
if (kind === "replace") annotationFields.Text.value = info?.texts[0] || "";
el.annotationCreateStatus.textContent = "";
el.annotationCreate.setAttribute("aria-label", {note:"添加批注",watermark:"添加水印",stamp:"添加印章",link: item ? "修改链接" : "添加链接",replace:"替换文字"}[kind]);
updateAnnotationFields();
el.annotationCreate.showModal();
const focus = [annotationFields.Text, annotationFields.Image, annotationFields.Address].find(input => !input.disabled);
focus?.focus();
}
function updateAnnotationFields() {
const { kind, item } = state.annotationCreate;
for (const key of ["X", "Y", "Width", "Height", "Pages", "Author"]) annotationFields[key].closest(".form-row").hidden = false;
for (const row of el.annotationCreate.querySelectorAll("[data-annotation]")) row.hidden = !row.dataset.annotation.split(" ").includes(kind);
const image = kind === "stamp" || kind === "watermark" && annotationFields.Content.value === "image";
annotationFields.ImageRow.hidden = !image;
annotationFields.Image.required = image;
annotationFields.TextRow.hidden = !["note", "replace", "watermark"].includes(kind) || image;
annotationFields.AddressRow.hidden = kind !== "link" || annotationFields.LinkKind.value !== "uri";
annotationFields.TargetRow.hidden = kind !== "link" || annotationFields.LinkKind.value !== "page";
annotationFields.Height.value = image ? "" : state.annotationCreate.height;
annotationFields.Height.placeholder = image ? "等比" : "";
if (kind === "link" && item) {
for (const key of ["X", "Y", "Width", "Height", "Pages", "Author"]) annotationFields[key].closest(".form-row").hidden = true;
}
for (const input of annotationFields.CreateForm.querySelectorAll("input,select,textarea")) {
input.disabled = Boolean(input.closest("[hidden]")) || image && input === annotationFields.Height;
}
}
async function editCanvasObject(item) {
if (!state.editing || document.body.hasAttribute("aria-busy") || canvasEditor.input || item.items) return;
if (canEditObject(item, "enter")) return enterCompositeScope(item.index, item.id);
if (item.type === "Annotation") {
editAnnotationDetails(item);
return; return;
} }
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;
} else if (!canEditObject(item, item.type === "ImageObject" ? "replaceImage" : "update")) return; } else if (!canEditObject(item, item.type === "ImageObject" ? "replaceImage" : "update")) return;
@@ -1440,13 +1600,7 @@ 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;
const annotations = state.renderAnnotations ? (page.annotations || []).filter(annotation => annotation.visible).map(annotation => ({ canvasEditor.mount(index, { ...page, objects: scope ? scope.objects : page.objects.filter(item => state.renderAnnotations || item.type !== "Annotation") }, surface);
...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) { function changeAnnotations(item, action, ...args) {
@@ -1904,7 +2058,7 @@ function updateDrawingControls() {
const tool = canvasEditor.tool; const tool = canvasEditor.tool;
const path = selectedPath(canvasEditor.selected); const path = selectedPath(canvasEditor.selected);
el.insertTextButton.setAttribute("aria-pressed", String(tool === "text")); el.insertTextButton.setAttribute("aria-pressed", String(tool === "text"));
const line = lineShape(tool) || !tool && lineShape(path?.shape); const line = lineShape(tool) || tool.startsWith("pen-") || !tool && lineShape(path?.shape);
if (line) { if (line) {
el.shapeFill.checked = false; el.shapeFill.checked = false;
el.shapeStroke.checked = true; el.shapeStroke.checked = true;
@@ -1913,6 +2067,9 @@ function updateDrawingControls() {
} }
const disabled = !state.editing || !state.ready || state.exporting; const disabled = !state.editing || !state.ready || state.exporting;
el.arrowTool.disabled = disabled || !pageCan("insert"); el.arrowTool.disabled = disabled || !pageCan("insert");
el.penTool.disabled = el.arrowTool.disabled;
el.penTool.classList.toggle("active", tool.startsWith("pen-"));
el.annotationTool.disabled = disabled;
for (const [button, name] of [[el.drawLineButton, "line"], [el.drawRectangleButton, "rectangle"], [el.drawEllipseButton, "ellipse"]]) { for (const [button, name] of [[el.drawLineButton, "line"], [el.drawRectangleButton, "rectangle"], [el.drawEllipseButton, "ellipse"]]) {
button.disabled = disabled || !pageCan("insert"); button.disabled = disabled || !pageCan("insert");
button.setAttribute("aria-pressed", String(tool === name)); button.setAttribute("aria-pressed", String(tool === name));
@@ -1953,11 +2110,15 @@ async function changeDocument(name, item, ...args) {
ofdgoDeleteObject: "delete", ofdgoDeleteObjects: "delete", ofdgoCopyObjects: "copy", ofdgoOrderObjects: "order", ofdgoGroupObjects: "group" }[name]; ofdgoDeleteObject: "delete", ofdgoDeleteObjects: "delete", ofdgoCopyObjects: "copy", ofdgoOrderObjects: "order", ofdgoGroupObjects: "group" }[name];
const members = item?.items || (item ? [item] : []); const members = item?.items || (item ? [item] : []);
const scoped = scope && members.length && members.every(member => member.scoped); const scoped = scope && members.length && members.every(member => member.scoped);
const inserting = ["ofdgoInsertShape", "ofdgoInsertText", "ofdgoInsertImage"].includes(name); const ids = item ? Array.isArray(item.id) ? item.id : members.map(member => member.id) : [];
const annotated = !scoped && ids.some(id => id.startsWith("annotation:"));
const inserting = ["ofdgoInsertShape", "ofdgoInsertText", "ofdgoInsertImage", "ofdgoInsertInk"].includes(name);
const editingAnnotation = name === "ofdgoWriteAnnotation" && Boolean(state.annotationCreate?.item);
if (scope && inserting) args.push(scope.key); if (scope && inserting) args.push(scope.key);
if (scoped && !operation) throw new Error("内部对象暂不支持此操作"); if (scoped && !operation) throw new Error("内部对象暂不支持此操作");
const doc = scoped const doc = scoped
? await callWASM("ofdgoChangeCompositeObjects", item.index, scope.key, Array.isArray(item.id) ? item.id : members.map(member => member.id), operation, ...args) ? await callWASM("ofdgoChangeCompositeObjects", item.index, scope.key, Array.isArray(item.id) ? item.id : members.map(member => member.id), operation, ...args)
: annotated && operation ? await callWASM("ofdgoChangeSelection", item.index, ids, operation, ...args)
: await callWASM(name, ...(item ? [item.index, item.id] : []), ...args); : await callWASM(name, ...(item ? [item.index, item.id] : []), ...args);
if (openSeq !== state.openSeq) { if (openSeq !== state.openSeq) {
return; return;
@@ -1975,7 +2136,7 @@ async function changeDocument(name, item, ...args) {
const restoredIndex = location ? doc.pages.findIndex(page => page.id === location.page) : -1; const restoredIndex = location ? doc.pages.findIndex(page => page.id === location.page) : -1;
if (restoredIndex >= 0) { if (restoredIndex >= 0) {
state.composite = location.scope ? { index: restoredIndex, key: location.scope, objects: [] } : null; state.composite = location.scope ? { index: restoredIndex, key: location.scope, objects: [] } : null;
} else if (!item && !inserting && !(name === "ofdgoPasteObjects" && args[4])) { } else if (!item && !inserting && !editingAnnotation && !(name === "ofdgoPasteObjects" && args[4])) {
resetCompositeScope(); resetCompositeScope();
} }
if (restoring && JSON.stringify(doc.outlines) !== JSON.stringify(state.doc.outlines)) { if (restoring && JSON.stringify(doc.outlines) !== JSON.stringify(state.doc.outlines)) {
@@ -2000,17 +2161,17 @@ async function changeDocument(name, item, ...args) {
rememberEditorView(before, revision); rememberEditorView(before, revision);
return true; return true;
} }
const clearSelection = !item || name === "ofdgoChangeAnnotations" && args[0] === "delete" const clearSelection = !item && !editingAnnotation || name === "ofdgoChangeAnnotations" && args[0] === "delete"
|| ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoEraseObjects", "ofdgoEraseObjectsPath"].includes(name); || ["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" || inserting || name === "ofdgoWriteAnnotation" && !editingAnnotation) {
state.selectObjects = true; state.selectObjects = true;
canvasEditor.setTool(""); if (name !== "ofdgoInsertInk") canvasEditor.setTool("");
setPan(false); setPan(false);
canvasEditor.pendingSelection = { index: item?.index ?? args[0], ids: doc.selectedIDs }; canvasEditor.pendingSelection = { index: item?.index ?? args[0], ids: name === "ofdgoInsertInk" ? [] : doc.selectedIDs };
} }
if (scoped && name === "ofdgoOrderObjects") canvasEditor.pendingSelection = { index: item.index, ids: doc.selectedIDs }; if (scoped && name === "ofdgoOrderObjects") canvasEditor.pendingSelection = { index: item.index, ids: doc.selectedIDs };
openSeq = ++state.openSeq; openSeq = ++state.openSeq;
if (item || name === "ofdgoPasteObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") { if (item || name === "ofdgoPasteObjects" || inserting || editingAnnotation) {
await refreshEditorPage(doc, item ? item.index : args[0], openSeq, clearSelection); await refreshEditorPage(doc, item ? item.index : args[0], openSeq, clearSelection);
} else { } else {
const samePage = doc.pages.findIndex((page) => page.id === previous.id); const samePage = doc.pages.findIndex((page) => page.id === previous.id);
@@ -2029,7 +2190,9 @@ async function changeDocument(name, item, ...args) {
} }
} catch (err) { } catch (err) {
if (openSeq === state.openSeq) { if (openSeq === state.openSeq) {
if (el.annotationNote.open) { if (el.annotationCreate.open) {
el.annotationCreateStatus.textContent = err.message;
} else if (el.annotationNote.open) {
el.annotationStatus.textContent = err.message; el.annotationStatus.textContent = err.message;
} else if (el.batchPagesPanel.open) { } else if (el.batchPagesPanel.open) {
el.batchPagesStatus.textContent = err.message; el.batchPagesStatus.textContent = err.message;
@@ -2588,6 +2751,8 @@ async function openOFD(file) {
el.paragraphPanel.close(); el.paragraphPanel.close();
el.infoPanel.close(); el.infoPanel.close();
el.annotationNote.close(); el.annotationNote.close();
el.annotationCreate.close();
el.objectPicker.close();
el.importPanel.close(); el.importPanel.close();
el.batchPagesPanel.close(); el.batchPagesPanel.close();
el.objectStylePanel.close(); el.objectStylePanel.close();
@@ -5800,7 +5965,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" ? "替换图片" : item?.type === "Annotation" ? "修改注解" : "修改对象"; el.editObjectButton.title = canEditObject(item, "enter") ? item?.type === "Annotation" ? "进入注解" : "进入组合" : 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);
+96 -16
View File
@@ -178,7 +178,6 @@ function paintShape(node, shape, box) {
export function canEditObject(item, capability) { export function canEditObject(item, capability) {
if (!item) return false; if (!item) return false;
const objects = item.items || [item]; 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)); return objects.every(object => Boolean(object.capabilities?.[capability] || capability === "move" && object.capabilities?.transform));
} }
@@ -191,7 +190,6 @@ 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 => {
@@ -231,6 +229,15 @@ export class CanvasEditor {
viewer.addEventListener("pointermove", (event) => this.move(event)); viewer.addEventListener("pointermove", (event) => this.move(event));
viewer.addEventListener("pointerup", (event) => this.end(event)); viewer.addEventListener("pointerup", (event) => this.end(event));
viewer.addEventListener("pointercancel", () => this.cancel()); viewer.addEventListener("pointercancel", () => this.cancel());
viewer.addEventListener("contextmenu", event => {
if (!this.enabled || this.input || this.options.busy()) return;
const items = this.itemsAt(event);
if (items.length > 1) {
event.preventDefault();
this.cancel();
this.options.onPick?.(items);
}
});
viewer.addEventListener("lostpointercapture", () => this.cancel()); viewer.addEventListener("lostpointercapture", () => this.cancel());
viewer.addEventListener("keydown", (event) => this.keyDown(event)); viewer.addEventListener("keydown", (event) => this.keyDown(event));
viewer.addEventListener("keyup", (event) => this.keyUp(event)); viewer.addEventListener("keyup", (event) => this.keyUp(event));
@@ -282,12 +289,10 @@ 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) => Number(Boolean(b.background)) - Number(Boolean(a.background)) const objects = [...page.objects].sort((a, b) => (artwork.get(a.id)?.order ?? 0) - (artwork.get(b.id)?.order ?? 0));
|| (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("edit-background", Boolean(object.background));
node.classList.toggle("read-only", !canEditObject(object, "move")); node.classList.toggle("read-only", !canEditObject(object, "move"));
node.title = objectEditReason(object); node.title = objectEditReason(object);
if (lineShape(object.shape)) { if (lineShape(object.shape)) {
@@ -298,7 +303,7 @@ export class CanvasEditor {
node.setAttribute("aria-label", { ImageObject: "图片对象", TextObject: "文字对象", PathObject: "图形对象", CompositeObject: "复合对象", CompositeGraphicUnit: "复合对象", Annotation: "注解对象" }[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.type === "Annotation") && object.contours?.length) {
node.classList.add("edit-contour"); node.classList.add("edit-contour");
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.classList.add("edit-outline"); svg.classList.add("edit-outline");
@@ -519,6 +524,7 @@ export class CanvasEditor {
} }
if (this.tool) { if (this.tool) {
if (this.tool.startsWith("erase-")) this.startErase(event); if (this.tool.startsWith("erase-")) this.startErase(event);
else if (this.tool.startsWith("pen-")) this.startInk(event);
else this.startShape(event); else this.startShape(event);
return; return;
} }
@@ -533,9 +539,8 @@ 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 || target.background && !this.items().includes(target)) { if (!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) {
@@ -587,6 +592,10 @@ export class CanvasEditor {
return [...surface.querySelectorAll(".edit-object")].map(node => this.nodes.get(node)).reverse().filter(item => this.contains(item, point, tolerance)); return [...surface.querySelectorAll(".edit-object")].map(node => this.nodes.get(node)).reverse().filter(item => this.contains(item, point, tolerance));
} }
pageItems(index) {
return [...this.viewer.querySelectorAll(".edit-object")].map(node => this.nodes.get(node)).filter(item => item?.index === index);
}
cycleSelection(event) { cycleSelection(event) {
const items = this.itemsAt(event); const items = this.itemsAt(event);
if (!items.length) return; if (!items.length) return;
@@ -602,6 +611,10 @@ export class CanvasEditor {
} }
event.preventDefault(); event.preventDefault();
drag.pointer = event; drag.pointer = event;
if (drag.ink) {
this.moveInk(event, drag);
return;
}
if (drag.erase) { if (drag.erase) {
this.moveErase(event, drag); this.moveErase(event, drag);
return; return;
@@ -729,6 +742,16 @@ export class CanvasEditor {
return; return;
} }
this.move(event); this.move(event);
if (drag.ink) {
this.drag = null;
this.viewer.releasePointerCapture(drag.pointerID);
if (drag.pressure && drag.points.every(point => point.pressure === 0)) {
drag.preview.remove();
return;
}
Promise.resolve(this.options.onDrawInk(drag.page.index, drag.points, drag.style, drag.pressure)).finally(() => drag.preview.remove());
return;
}
if (drag.erase) { if (drag.erase) {
const items = drag.erase === "erase-object" ? [...drag.erased] : drag.box 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 ? drag.items.filter(item => item.x < drag.box.x + drag.box.width && item.x + item.width > drag.box.x
@@ -746,12 +769,19 @@ 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) {
this.drag = null; this.drag = null;
this.viewer.releasePointerCapture(drag.pointerID); this.viewer.releasePointerCapture(drag.pointerID);
if (drag.annotation) {
const point = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.page, drag.rotation);
const box = drag.box || { ...point, width: drag.annotation === "note" ? 5 : 40, height: drag.annotation === "note" ? 5 : 12 };
drag.preview.remove();
this.setTool("");
this.options.onDrawAnnotation(drag.page.index, drag.annotation, box);
return;
}
if (drag.shape === "text") { if (drag.shape === "text") {
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 x = Math.max(0, Math.min(from.x, drag.page.width - 1)); const x = Math.max(0, Math.min(from.x, drag.page.width - 1));
@@ -812,14 +842,56 @@ export class CanvasEditor {
} }
} }
startInk(event) {
const surface = event.target.closest(".page-surface") || this.nearestSurface(event), page = this.pages.get(surface);
if (!page || !this.options.canInsert(page.index)) return;
event.preventDefault();
this.focus();
const style = { ...this.options.drawStyle(), fill: false, stroke: true };
const pressure = this.tool === "pen-pressure";
const preview = this.createPreview(surface, page, "path", pressure ? { ...style, fill: true, fillColor: style.strokeColor, stroke: false } : style);
preview.node.setAttribute("stroke-linecap", "round");
preview.node.setAttribute("stroke-linejoin", "round");
this.drag = { ink: true, pressure, points: [], parts: [], page, surface, style, ...preview, pointerID: event.pointerId,
rect: surface.getBoundingClientRect(), rotation: this.options.rotation() };
this.viewer.setPointerCapture(event.pointerId);
this.moveInk(event, this.drag);
}
moveInk(event, drag) {
const coalesced = event.getCoalescedEvents?.() || [];
for (const sample of coalesced.length ? coalesced : [event]) {
const point = pagePoint(sample.clientX, sample.clientY, drag.rect, drag.page, drag.rotation);
const last = drag.points.at(-1);
point.pressure = sample.pointerType === "pen" ? event.type === "pointerup" ? last?.pressure ?? sample.pressure : sample.pressure : 0.5;
if (last && Math.hypot(point.x-last.x, point.y-last.y) < 0.05 && Math.abs(point.pressure-last.pressure) < 0.01) continue;
drag.points.push(point);
if (!drag.pressure) {
drag.parts.push(`${last ? "L" : "M"}${point.x} ${point.y}`);
} else {
const radius = drag.style.lineWidth * point.pressure / 2;
if (radius > 0) drag.parts.push(`M${point.x+radius} ${point.y}a${radius} ${radius} 0 1 1 ${-2*radius} 0a${radius} ${radius} 0 1 1 ${2*radius} 0Z`);
if (last) {
const r = drag.style.lineWidth * last.pressure / 2, dx = point.x-last.x, dy = point.y-last.y, length = Math.hypot(dx,dy);
if ((radius > 0 || r > 0) && length > Math.abs(radius-r)) {
const x = dx/length, y = dy/length, a = (r-radius)/length, b = Math.sqrt(1-a*a);
const nx = a*x-b*y, ny = a*y+b*x, mx = a*x+b*y, my = a*y-b*x;
drag.parts.push(`M${last.x+r*mx} ${last.y+r*my}L${point.x+radius*mx} ${point.y+radius*my}L${point.x+radius*nx} ${point.y+radius*ny}L${last.x+r*nx} ${last.y+r*ny}Z`);
}
}
}
}
const dot = drag.points.length === 1 && !drag.pressure ? `l0.001 0` : "";
drag.node.setAttribute("d", drag.parts.join(" ") + dot);
}
startErase(event) { startErase(event) {
const surface = event.target.closest(".page-surface") || this.nearestSurface(event); const surface = event.target.closest(".page-surface") || this.nearestSurface(event);
const page = this.pages.get(surface); const page = this.pages.get(surface);
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",
@@ -875,12 +947,10 @@ 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 && item.x+item.width <= box.x+box.width && item.y >= box.y && item.y+item.height <= box.y+box.height) {
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);
} }
} }
@@ -895,13 +965,14 @@ export class CanvasEditor {
} }
event.preventDefault(); event.preventDefault();
this.focus(); this.focus();
const shape = this.tool; const annotation = this.tool.startsWith("annotation:") ? this.tool.slice(11) : "";
const shape = annotation ? "rectangle" : this.tool;
const style = this.options.drawStyle(); const style = this.options.drawStyle();
if (lineShape(shape)) { if (lineShape(shape)) {
style.fill = false; style.fill = false;
style.stroke = true; style.stroke = true;
} }
this.drag = { shape, page, surface, ...this.createPreview(surface, page, shape === "text" ? "rectangle" : shape, style), style, pointerID: event.pointerId, this.drag = { shape, annotation, page, surface, ...this.createPreview(surface, page, shape === "text" ? "rectangle" : shape, style), style, pointerID: event.pointerId,
rect: surface.getBoundingClientRect(), rotation: this.options.rotation(), rect: surface.getBoundingClientRect(), rotation: this.options.rotation(),
clientX: event.clientX, clientY: event.clientY }; clientX: event.clientX, clientY: event.clientY };
this.viewer.setPointerCapture(event.pointerId); this.viewer.setPointerCapture(event.pointerId);
@@ -1026,6 +1097,15 @@ export class CanvasEditor {
if (this.input || !this.enabled || !this.selected || this.options.busy() || this.nudgeCommit || event.ctrlKey || event.metaKey || event.altKey || event.isComposing) { if (this.input || !this.enabled || !this.selected || this.options.busy() || this.nudgeCommit || event.ctrlKey || event.metaKey || event.altKey || event.isComposing) {
return; return;
} }
if (event.key === "Tab" && !this.drag && !this.nudge) {
const items = this.pageItems(this.selected.index);
if (items.length) {
event.preventDefault();
event.stopPropagation();
this.select(items[(items.indexOf(this.selected) + (event.shiftKey ? -1 : 1) + items.length) % items.length]);
}
return;
}
const directions = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }; const directions = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
const edit = event.key === "Enter" || event.key === "F2"; const edit = event.key === "Enter" || event.key === "F2";
if (event.key !== "Escape" && event.key !== "Delete" && !edit && !directions[event.key]) { if (event.key !== "Escape" && event.key !== "Delete" && !edit && !directions[event.key]) {
+656 -3
View File
@@ -29,6 +29,7 @@ import (
"io" "io"
"math" "math"
"path" "path"
"slices"
"strconv" "strconv"
"strings" "strings"
"syscall/js" "syscall/js"
@@ -119,6 +120,10 @@ func RunWASM() {
registerCallback("ofdgoBatchPages", batchPages) registerCallback("ofdgoBatchPages", batchPages)
registerCallback("ofdgoChangeOutline", changeOutline) registerCallback("ofdgoChangeOutline", changeOutline)
registerCallback("ofdgoChangeAnnotations", changeAnnotations) registerCallback("ofdgoChangeAnnotations", changeAnnotations)
registerCallback("ofdgoChangeSelection", changeSelection)
registerCallback("ofdgoWriteAnnotation", writeAnnotation)
registerCallback("ofdgoReadAnnotation", readAnnotation)
registerCallback("ofdgoInsertInk", insertInk)
registerCallback("ofdgoStyleObjects", styleObjects) registerCallback("ofdgoStyleObjects", styleObjects)
registerCallback("ofdgoMoveOutline", moveOutline) registerCallback("ofdgoMoveOutline", moveOutline)
registerCallback("ofdgoCaptureStyle", captureStyle) registerCallback("ofdgoCaptureStyle", captureStyle)
@@ -638,6 +643,7 @@ type editorClipboard struct {
token string token string
objects []ofdgo.GraphicObject objects []ofdgo.GraphicObject
composite *ofdgo.CompositeSelection composite *ofdgo.CompositeSelection
annotations *ofdgo.AnnotationSelection
} }
// copiedObjects 当前对象剪贴板,不保存字体或图片的重复数据 // copiedObjects 当前对象剪贴板,不保存字体或图片的重复数据
@@ -852,6 +858,50 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
} }
} }
} }
for _, annotation := range currentSession.Reader.Annots[page.ID] {
if annotation.Visible != nil && !*annotation.Visible {
continue
}
box, contours, err := currentSession.Renderer.AnnotationGeometry(annotation)
capabilities := map[string]any{"update": true, "transform": true, "arrange": true, "copy": true, "delete": true, "enter": len(annotation.Appearance.Objects) != 0}
if err != nil {
capabilities["transform"], capabilities["arrange"], capabilities["enter"] = false, false, false
capabilities["reason"], capabilities["reasonCode"] = err.Error(), "invalidObject"
}
if box.W <= 0 || box.H <= 0 {
box, _ = ofdgo.ParseBox(annotation.Appearance.Boundary)
}
paths := make([]any, len(contours))
for i, contour := range contours {
paths[i] = map[string]any{"path": contour.Path, "evenOdd": contour.EvenOdd}
}
alpha, mixed := 255, false
for i, object := range annotation.Appearance.Objects {
var value *int
switch object.Type {
case "TextObject":
value = object.TextObject.Alpha
case "PathObject":
value = object.PathObject.Alpha
case "ImageObject":
value = object.ImageObject.Alpha
case "CompositeObject", "CompositeGraphicUnit":
value = object.CompositeGraphicUnit.Alpha
}
current := 255
if value != nil {
current = *value
}
if i == 0 {
alpha = current
} else if current != alpha {
mixed = true
}
}
objects = append(objects, map[string]any{"id": "annotation:" + annotation.ID, "type": "Annotation", "annotationType": annotation.Type,
"x": box.X, "y": box.Y, "width": box.W, "height": box.H, "contours": paths, "remark": annotation.Remark, "creator": annotation.Creator,
"alpha": alpha, "alphaMixed": mixed, "capabilities": capabilities})
}
return objects, nil return objects, nil
} }
@@ -880,6 +930,597 @@ func changeAnnotations(args []js.Value) (any, error) {
}) })
} }
// annotationOptions 保存注解面板的输入,资源字节单独传输
type annotationOptions struct {
Kind, ID, Text, Old, Creator, Pages, URI, Color string
Base string
X, Y, Width, Height, Size, Angle float64
Alpha int
Tile bool
Keep bool
Target int
}
// readAnnotation 获取编辑面板所需的注解内容,不修改文件
// 入参: args 页面索引与注解标识
// 返回: any 面板数据, error 错误信息
func readAnnotation(args []js.Value) (any, error) {
annotation, err := currentEditor.Annotation(args[0].Int(), strings.TrimPrefix(args[1].String(), "annotation:"))
if err != nil {
return nil, err
}
texts := make([]string, 0)
visiting := make(map[string]bool)
var collect func([]ofdgo.GraphicObject)
collect = func(objects []ofdgo.GraphicObject) {
for _, object := range objects {
switch object.Type {
case "TextObject":
value := object.TextObject.Text()
if !slices.Contains(texts, value) {
texts = append(texts, value)
}
case "CompositeObject", "CompositeGraphicUnit":
collect(object.CompositeGraphicUnit.Objects)
id := object.CompositeGraphicUnit.ResourceID
if unit := currentSession.Renderer.CompositeGraphicUnits[id]; unit != nil && !visiting[id] {
visiting[id] = true
collect(unit.Objects)
delete(visiting, id)
}
}
}
}
collect(annotation.Appearance.Objects)
info := map[string]any{"texts": texts, "remark": annotation.Remark, "creator": annotation.Creator}
if annotation.Type == "Link" {
info["linkKind"] = "keep"
link, err := currentEditor.AnnotationLink(args[0].Int(), annotation.ID)
if err == nil && link.URI != nil {
info["linkKind"], info["linkURI"], info["linkBase"] = "uri", link.URI.URI, link.URI.Base
} else if err == nil && link.Goto != nil && link.Goto.Dest != nil {
for i, page := range currentSession.doc.Pages.Page {
if page.ID == link.Goto.Dest.PageID {
info["linkKind"], info["linkPage"] = "page", i+1
break
}
}
}
}
return info, nil
}
// writeAnnotation 原子创建多页注解或修改现有链接、重复文字
// 入参: args 当前页面、JSON选项、字体字节、图片字节
// 返回: any 文档信息及新选区, error 错误信息
func writeAnnotation(args []js.Value) (any, error) {
var options annotationOptions
if err := json.Unmarshal([]byte(args[1].String()), &options); err != nil {
return nil, err
}
if options.Kind == "link" && options.ID != "" && options.Keep {
return editorSummary(), nil
}
page := args[0].Int()
var ids []string
result, err := changeObjects(func() error {
font, resource := "", ""
if !args[2].IsNull() {
data, err := bytesFromJS(args[2])
if err != nil {
return err
}
font, err = currentEditor.AddFont(FontFile{Data: data}, 0)
if err != nil {
return err
}
}
if !args[3].IsNull() {
data, err := bytesFromJS(args[3])
if err != nil {
return err
}
resource, err = currentEditor.AddImage(data)
if err != nil {
return err
}
config, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return err
}
options.Height = options.Width * float64(config.Height) / float64(config.Width)
}
if options.Kind == "replace" {
style := ofdgo.TextStyle{Font: font}
_, err := currentEditor.ReplaceAnnotationText(page, options.ID, options.Old, options.Text, style)
return err
}
target := ofdgo.AnnotationLink{URI: &ofdgo.URI{URI: options.URI, Base: options.Base}}
if options.Target > 0 {
if options.Target > currentEditor.PageCount() {
return fmt.Errorf("destination page is out of range")
}
page, err := currentEditor.Page(options.Target - 1)
if err != nil {
return err
}
target = ofdgo.AnnotationLink{Dest: &ofdgo.Dest{Type: "Fit", PageID: page.ID}}
}
if options.Kind == "link" && options.ID != "" {
return currentEditor.UpdateAnnotationLink(page, options.ID, target)
}
if options.Width <= 0 || options.Height <= 0 || options.Alpha < 0 || options.Alpha > 255 {
return fmt.Errorf("invalid annotation dimensions or opacity")
}
pages, err := ofdgo.ParsePageRange(options.Pages, currentEditor.PageCount())
if err != nil {
return err
}
for _, index := range pages {
box := ofdgo.Box{X: options.X, Y: options.Y, W: options.Width, H: options.Height}
var id string
if options.Kind == "link" {
id, err = currentEditor.AddLinkAnnotation(index, box, target, options.Creator)
} else {
annotation := ofdgo.Annotation{Creator: options.Creator, Appearance: ofdgo.Appearance{Boundary: fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H)}}
switch options.Kind {
case "note":
annotation.Type, annotation.Remark = "Path", options.Text
shape, shapeErr := ofdgo.NewShape(ofdgo.ShapeRectangle, ofdgo.Box{W: box.W, H: box.H})
if shapeErr != nil {
return shapeErr
}
shape.LineWidth = 0.3
shape.StrokeColor = &ofdgo.StrokeColor{Value: "0 128 112"}
annotation.Appearance.Objects = []ofdgo.GraphicObject{{Type: "PathObject", PathObject: shape}}
case "watermark", "stamp":
annotation.Type = "Watermark"
if options.Kind == "stamp" {
annotation.Type = "Stamp"
}
var object ofdgo.GraphicObject
if resource != "" {
object = ofdgo.GraphicObject{Type: "ImageObject", ImageObject: ofdgo.ImageObject{ResourceID: resource, Boundary: fmt.Sprintf("0 0 %g %g", box.W, box.H), CTM: fmt.Sprintf("%g 0 0 %g 0 0", box.W, box.H), Alpha: &options.Alpha}}
} else {
if options.Kind == "stamp" {
return fmt.Errorf("stamp requires an image")
}
text := ofdgo.TextObject{Font: font, Size: options.Size, Boundary: fmt.Sprintf("0 0 %g %g", box.W, box.H), Alpha: &options.Alpha}
if err := currentEditor.LayoutText(&text, options.Text, ofdgo.TextLayout{Align: "center"}); err != nil {
return err
}
if err := setTextColor(&text, options.Color); err != nil {
return err
}
object = ofdgo.GraphicObject{Type: "TextObject", TextObject: text}
}
angle := options.Angle * math.Pi / 180
matrix := ofdgo.NewMatrix(fmt.Sprintf("%g %g %g %g 0 0", math.Cos(angle), math.Sin(angle), -math.Sin(angle), math.Cos(angle)))
matrix = ofdgo.TranslationMatrix(box.W/2, box.H/2).Multiply(matrix).Multiply(ofdgo.TranslationMatrix(-box.W/2, -box.H/2))
if object.Type == "TextObject" {
object.TextObject.CTM = matrix.String()
} else {
object.ImageObject.CTM = matrix.Multiply(ofdgo.NewMatrix(object.ImageObject.CTM)).String()
}
annotation.Appearance.Objects = []ofdgo.GraphicObject{object}
default:
return fmt.Errorf("unsupported annotation kind %q", options.Kind)
}
if options.Tile && options.Kind == "watermark" {
content, err := currentSession.pageContent(index)
if err != nil {
return err
}
page, err := currentSession.pageBox(index, content)
if err != nil {
return err
}
annotation.Appearance.Boundary = fmt.Sprintf("0 0 %g %g", page.W, page.H)
base := annotation.Appearance.Objects[0]
annotation.Appearance.Objects = nil
for y := box.Y; y < page.H; y += box.H + 10 {
for x := box.X; x < page.W; x += box.W + 10 {
object := base
boundary := fmt.Sprintf("%g %g %g %g", x, y, box.W, box.H)
if object.Type == "TextObject" {
object.TextObject.Boundary = boundary
} else {
object.ImageObject.Boundary = boundary
}
annotation.Appearance.Objects = append(annotation.Appearance.Objects, object)
}
}
}
id, err = currentEditor.AddAnnotation(index, annotation)
}
if err != nil {
return err
}
if index == page {
ids = append(ids, "annotation:"+id)
}
}
return nil
})
if err != nil {
return nil, err
}
return editorSelectionInfo{editorInfo: result.(editorInfo), SelectedIDs: ids}, nil
}
// insertInk 将一笔采样写为一个标准矢量对象,复用内部插入与撤销逻辑
// 入参: args 页面、JSON采样、线宽、压感开关、颜色及可选父路径
// 返回: any 文档和选区, error 错误信息
func insertInk(args []js.Value) (any, error) {
var points []ofdgo.InkPoint
if err := json.Unmarshal([]byte(args[1].String()), &points); err != nil {
return nil, err
}
path, err := ofdgo.NewInk(points, args[2].Float(), args[3].Bool())
if err != nil {
return nil, err
}
if path.Fill != nil && *path.Fill {
err = setEditorColor(path.FillColor, args[4].String())
} else {
err = setEditorColor((*ofdgo.FillColor)(path.StrokeColor), args[4].String())
}
if err != nil {
return nil, err
}
return insertEditorObject(args[0].Int(), ofdgo.GraphicObject{Type: "PathObject", PathObject: path}, args[5:])
}
// splitEditorSelection 区分页面对象与注解标识,不以外观大小或类型猜测
// 入参: ids 画布选区
// 返回: []string 正文标识, []string 注解标识
func splitEditorSelection(ids []string) ([]string, []string) {
var objects, annotations []string
for _, id := range ids {
if value, ok := strings.CutPrefix(id, "annotation:"); ok {
annotations = append(annotations, value)
} else {
objects = append(objects, id)
}
}
return objects, annotations
}
// changeSelection 原子处理正文和注解混选,保持绘制顺序及统一历史
// 入参: args 页面索引、标识列表、操作与参数
// 返回: any 文档与新选区, error 错误信息
func changeSelection(args []js.Value) (any, error) {
var selected []string
result, err := changeObjects(func() error {
page, operation := args[0].Int(), args[2].String()
objects, annotations := splitEditorSelection(stringsFromJS(args[1]))
switch operation {
case "align", "distribute":
return arrangeEditorSelection(page, stringsFromJS(args[1]), operation, args[3].String())
case "delete":
if err := currentEditor.DeleteObjects(page, objects); err != nil {
return err
}
return currentEditor.DeleteAnnotations(page, annotations)
case "copy":
clipboard, err := captureEditorSelection(page, stringsFromJS(args[1]))
if err != nil {
return err
}
selected, err = pasteEditorSelection(page, clipboard, args[3].Float(), args[4].Float())
return err
case "style":
style := objectStyle(args[3])
if err := currentEditor.StyleObjects(page, objects, style); err != nil {
return err
}
for _, id := range annotations {
path := ofdgo.ObjectPath{Annotation: id}
members, err := currentEditor.CompositeObjects(page, path)
if err != nil {
return err
}
indexes := make([]int, len(members))
for i := range indexes {
indexes[i] = i
}
if err := currentEditor.StyleCompositeObjects(page, path, indexes, style); err != nil {
return err
}
}
return nil
case "erase", "erasePath":
var box ofdgo.Box
var points []ofdgo.Point
if operation == "erase" {
box = ofdgo.Box{X: args[3].Float(), Y: args[4].Float(), W: args[5].Float(), H: args[6].Float()}
if err := currentEditor.EraseObjects(page, objects, box); err != nil {
return err
}
} else {
points = pointsFromJS(args[3])
if err := currentEditor.EraseObjectsPath(page, objects, points); err != nil {
return err
}
}
for _, id := range annotations {
path := ofdgo.ObjectPath{Annotation: id}
members, err := currentEditor.CompositeObjects(page, path)
if err != nil {
return err
}
indexes := make([]int, len(members))
for i := range indexes {
indexes[i] = i
}
if operation == "erase" {
err = currentEditor.EraseCompositeObjects(page, path, indexes, box)
} else {
err = currentEditor.EraseCompositeObjectsPath(page, path, indexes, points)
}
if err != nil {
return err
}
}
return nil
}
matrix := ofdgo.IdentityMatrix
if operation == "transform" {
dx, dy, scale := args[3].Float(), args[4].Float(), args[5].Float()
if scale <= 0 {
return fmt.Errorf("scale must be positive")
}
matrix = ofdgo.NewMatrix(fmt.Sprintf("%g 0 0 %g %g %g", scale, scale, dx, dy))
} else {
bounds, err := editorSelectionBounds(page, objects, annotations)
if err != nil {
return err
}
x, y := bounds.X+bounds.W/2, bounds.Y+bounds.H/2
switch operation {
case "rotate":
angle := float64(args[3].Int()) * math.Pi / 180
matrix = ofdgo.NewMatrix(fmt.Sprintf("%g %g %g %g 0 0", math.Cos(angle), math.Sin(angle), -math.Sin(angle), math.Cos(angle)))
case "flip":
if args[3].String() == "horizontal" {
matrix = ofdgo.NewMatrix("-1 0 0 1 0 0")
} else if args[3].String() == "vertical" {
matrix = ofdgo.NewMatrix("1 0 0 -1 0 0")
} else {
return fmt.Errorf("invalid flip axis")
}
case "resize":
sx, sy := args[5].Float()/bounds.W, args[6].Float()/bounds.H
if sx <= 0 || sy <= 0 {
return fmt.Errorf("size must be positive")
}
matrix = ofdgo.NewMatrix(fmt.Sprintf("%g 0 0 %g %g %g", sx, sy, args[3].Float()-bounds.X*sx, args[4].Float()-bounds.Y*sy))
default:
return fmt.Errorf("unsupported mixed selection operation %q", operation)
}
if operation != "resize" {
matrix = ofdgo.TranslationMatrix(x, y).Multiply(matrix).Multiply(ofdgo.TranslationMatrix(-x, -y))
}
}
if err := currentEditor.TransformObjectsMatrix(page, objects, matrix); err != nil {
return err
}
return currentEditor.TransformAnnotations(page, annotations, matrix)
})
if err != nil || selected == nil {
return result, err
}
return editorSelectionInfo{editorInfo: result.(editorInfo), SelectedIDs: selected}, nil
}
// arrangeEditorSelection 对正文和注解统一对齐或分布,不改变各自绘制层级
// 入参: page 页面, ids 标识, operation 操作, direction 方向
// 返回: error 错误信息
func arrangeEditorSelection(page int, ids []string, operation, direction string) error {
if operation == "align" && !slices.Contains([]string{"left", "center", "right", "top", "middle", "bottom"}, direction) || operation == "distribute" && direction != "horizontal" && direction != "vertical" {
return fmt.Errorf("invalid arrangement direction")
}
boxes := make([]ofdgo.Box, len(ids))
var target ofdgo.Box
for i, id := range ids {
objects, annotations := splitEditorSelection([]string{id})
box, err := editorSelectionBounds(page, objects, annotations)
if err != nil {
return err
}
boxes[i] = box
if i == 0 {
target = box
} else {
x, y := math.Min(target.X, box.X), math.Min(target.Y, box.Y)
target = ofdgo.Box{X: x, Y: y, W: math.Max(target.X+target.W, box.X+box.W) - x, H: math.Max(target.Y+target.H, box.Y+box.H) - y}
}
}
if len(ids) == 1 {
content, err := currentSession.pageContent(page)
if err != nil {
return err
}
target, err = currentSession.pageBox(page, content)
if err != nil {
return err
}
}
translations := make([]ofdgo.Point, len(ids))
if operation == "align" {
for i, box := range boxes {
switch direction {
case "left":
translations[i].X = target.X - box.X
case "center":
translations[i].X = target.X + (target.W-box.W)/2 - box.X
case "right":
translations[i].X = target.X + target.W - box.X - box.W
case "top":
translations[i].Y = target.Y - box.Y
case "middle":
translations[i].Y = target.Y + (target.H-box.H)/2 - box.Y
case "bottom":
translations[i].Y = target.Y + target.H - box.Y - box.H
}
}
} else {
if len(ids) < 3 {
return nil
}
order := make([]int, len(ids))
position := func(i int) float64 {
if direction == "vertical" {
return boxes[i].Y
}
return boxes[i].X
}
size := func(i int) float64 {
if direction == "vertical" {
return boxes[i].H
}
return boxes[i].W
}
for i := range order {
order[i] = i
}
slices.SortStableFunc(order, func(a, b int) int {
if position(a) < position(b) {
return -1
}
if position(a) > position(b) {
return 1
}
return 0
})
total := 0.0
for _, i := range order {
total += size(i)
}
first, last := order[0], order[len(order)-1]
gap := (position(last) + size(last) - position(first) - total) / float64(len(order)-1)
next := position(first)
for _, i := range order {
if direction == "vertical" {
translations[i].Y = next - position(i)
} else {
translations[i].X = next - position(i)
}
next += size(i) + gap
}
}
for i, id := range ids {
objects, annotations := splitEditorSelection([]string{id})
matrix := ofdgo.TranslationMatrix(translations[i].X, translations[i].Y)
if err := currentEditor.TransformObjectsMatrix(page, objects, matrix); err != nil {
return err
}
if err := currentEditor.TransformAnnotations(page, annotations, matrix); err != nil {
return err
}
}
return nil
}
// editorSelectionBounds 计算混合选区的实际内容范围
// 入参: page 页面索引, objects 正文标识, annotations 注解标识
// 返回: ofdgo.Box 页面毫米范围, error 错误信息
func editorSelectionBounds(page int, objects, annotations []string) (ofdgo.Box, error) {
content, err := currentEditor.Page(page)
if err != nil {
return ofdgo.Box{}, err
}
var bounds ofdgo.Box
add := func(box ofdgo.Box) {
if box.W <= 0 || box.H <= 0 {
return
}
if bounds.W <= 0 || bounds.H <= 0 {
bounds = box
return
}
x, y := math.Min(bounds.X, box.X), math.Min(bounds.Y, box.Y)
bounds = ofdgo.Box{X: x, Y: y, W: math.Max(bounds.X+bounds.W, box.X+box.W) - x, H: math.Max(bounds.Y+bounds.H, box.Y+box.H) - y}
}
for _, layer := range content.Content.Layer {
for _, object := range layer.Objects {
var id string
switch object.Type {
case "TextObject":
id = object.TextObject.ID
case "PathObject":
id = object.PathObject.ID
case "ImageObject":
id = object.ImageObject.ID
case "CompositeObject", "CompositeGraphicUnit":
id = object.CompositeGraphicUnit.ID
}
if slices.Contains(objects, id) {
box, err := currentSession.Renderer.ObjectBounds(object, layer.DrawParam)
if err != nil {
return bounds, err
}
add(box)
}
}
}
for _, annotation := range currentSession.Reader.Annots[content.ID] {
if slices.Contains(annotations, annotation.ID) {
box, _, err := currentSession.Renderer.AnnotationGeometry(annotation)
if err != nil {
return bounds, err
}
if box.W <= 0 || box.H <= 0 {
box, _ = ofdgo.ParseBox(annotation.Appearance.Boundary)
}
add(box)
}
}
if bounds.W <= 0 || bounds.H <= 0 {
return bounds, fmt.Errorf("selection has no bounds")
}
return bounds, nil
}
// captureEditorSelection 捕获正文与注解的独立副本
// 入参: page 页面索引, ids 画布标识
// 返回: *editorClipboard 快照, error 错误信息
func captureEditorSelection(page int, ids []string) (*editorClipboard, error) {
objects, annotations := splitEditorSelection(ids)
result := &editorClipboard{}
var err error
result.objects, err = currentEditor.Objects(page, objects)
if err == nil && len(annotations) != 0 {
result.annotations, err = currentEditor.CaptureAnnotations(page, annotations)
}
return result, err
}
// pasteEditorSelection 原子粘贴正文和注解,共享字体与图片数据
// 入参: page 目标页, clipboard 快照, dx、dy 位移
// 返回: []string 新画布标识, error 错误信息
func pasteEditorSelection(page int, clipboard *editorClipboard, dx, dy float64) ([]string, error) {
var ids []string
err := currentEditor.Transaction(func(edit *ofdgo.Editor) error {
var err error
ids, err = edit.CopyObjects(page, clipboard.objects, dx, dy)
if err != nil {
return err
}
if clipboard.annotations != nil {
annotations, err := edit.PasteAnnotations(page, clipboard.annotations, dx, dy)
if err != nil {
return err
}
for _, id := range annotations {
ids = append(ids, "annotation:"+id)
}
}
return nil
})
return ids, err
}
// editorCapabilities 统一顶层与内部对象的操作能力和受限原因 // editorCapabilities 统一顶层与内部对象的操作能力和受限原因
// 入参: capability 库层能力, kind 对象类型 // 入参: capability 库层能力, kind 对象类型
// 返回: map[string]any 前端能力 // 返回: map[string]any 前端能力
@@ -932,6 +1573,9 @@ func editorAppearance(item map[string]any, object ofdgo.GraphicObject, paint boo
func compositePath(value string) (ofdgo.ObjectPath, error) { func compositePath(value string) (ofdgo.ObjectPath, error) {
parts := strings.Split(value, "/") parts := strings.Split(value, "/")
path := ofdgo.ObjectPath{ID: parts[0]} path := ofdgo.ObjectPath{ID: parts[0]}
if id, ok := strings.CutPrefix(path.ID, "annotation:"); ok {
path.ID, path.Annotation = "", id
}
for _, part := range parts[1:] { for _, part := range parts[1:] {
index, err := strconv.Atoi(part) index, err := strconv.Atoi(part)
if err != nil || index < 0 { if err != nil || index < 0 {
@@ -1413,7 +2057,8 @@ func captureObjects(args []js.Value) (any, error) {
} }
clipboard.composite, err = currentEditor.CaptureCompositeObjects(args[0].Int(), path, indexes) clipboard.composite, err = currentEditor.CaptureCompositeObjects(args[0].Int(), path, indexes)
} else { } else {
clipboard.objects, err = currentEditor.Objects(args[0].Int(), stringsFromJS(args[1])) clipboard, err = captureEditorSelection(args[0].Int(), stringsFromJS(args[1]))
clipboard.token = args[2].String()
} }
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1435,6 +2080,9 @@ func pasteObjects(args []js.Value) (any, error) {
} }
page, dx, dy := args[0].Int(), args[2].Float(), args[3].Float() page, dx, dy := args[0].Int(), args[2].Float(), args[3].Float()
if key != "" { if key != "" {
if copiedObjects.annotations != nil {
return nil, fmt.Errorf("annotations cannot be nested in a page object")
}
path, err := compositePath(key) path, err := compositePath(key)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1463,7 +2111,12 @@ func pasteObjects(args []js.Value) (any, error) {
info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
return editorSelectionInfo{info, ids}, err return editorSelectionInfo{info, ids}, err
} }
return copyEditorObjects(page, copiedObjects.objects, dx, dy) ids, err := pasteEditorSelection(page, copiedObjects, dx, dy)
if err != nil {
return nil, err
}
info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
return editorSelectionInfo{info, ids}, err
} }
// copyEditorObjects 复制快照并更新预览,返回新对象标识 // copyEditorObjects 复制快照并更新预览,返回新对象标识
@@ -2448,7 +3101,7 @@ func changeObjects(apply func() error) (any, error) {
return nil, fmt.Errorf("no document is being edited") return nil, fmt.Errorf("no document is being edited")
} }
revision := currentEditor.Revision() revision := currentEditor.Revision()
if err := apply(); err != nil { if err := currentEditor.Transaction(func(*ofdgo.Editor) error { return apply() }); err != nil {
return nil, err return nil, err
} }
if currentEditor.Revision() == revision { if currentEditor.Revision() == revision {
+5
View File
@@ -156,8 +156,13 @@ func (r *Reader) parseAnnotations(doc *Document) error {
if r.Annots == nil { if r.Annots == nil {
r.Annots = make(map[string][]Annotation) r.Annots = make(map[string][]Annotation)
} }
r.annotationFiles = make(map[string][]string)
for _, page := range annotations.Page { for _, page := range annotations.Page {
annotPath := resolveResourcePath(annPath, "", page.FileLoc) annotPath := resolveResourcePath(annPath, "", page.FileLoc)
if file, ok := r.packageFile(annotPath); ok {
annotPath = cleanPackagePath(file.Name)
}
r.annotationFiles[annotPath] = append(r.annotationFiles[annotPath], page.PageID)
af, err := r.openFile(annotPath) af, err := r.openFile(annotPath)
if err != nil { if err != nil {
continue continue
+1 -22
View File
@@ -18,7 +18,6 @@ import (
"bytes" "bytes"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"maps"
"path" "path"
"strings" "strings"
"time" "time"
@@ -284,25 +283,5 @@ func (e *Editor) editAnnotations(index int, ids []string, edit func([]byte, *edi
if len(parts) == 0 { if len(parts) == 0 {
return nil return nil
} }
files := maps.Clone(reader.files) return e.commitAnnotationParts(e.source, parts)
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
} }
+455
View File
@@ -0,0 +1,455 @@
// 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"
"slices"
"strings"
"time"
)
// AddAnnotation 新增标准注解并分配独立标识,外观中的基本对象使用注解局部毫米坐标
// Appearance.Objects为空时使用分类对象集合,输入对象及资源不被修改
// 入参: index 页面索引, annotation 注解内容,ID和LastModDate由编辑器生成
// 返回: string 注解标识, error 错误信息
func (e *Editor) AddAnnotation(index int, annotation Annotation) (string, error) {
var id string
err := e.Transaction(func(next *Editor) error {
if index < 0 || index >= len(next.pages) {
return fmt.Errorf("page index %d out of range", index)
}
if !slices.Contains([]string{"Link", "Path", "Highlight", "Stamp", "Watermark"}, annotation.Type) {
return fmt.Errorf("invalid annotation type %q", annotation.Type)
}
if _, err := creationBox(annotation.Appearance.Boundary); err != nil {
return err
}
if err := next.prepareSourceIDs(); err != nil {
return err
}
id = next.nextID()
objects := annotation.Appearance.Objects
if len(objects) == 0 {
for _, object := range annotation.Appearance.TextObject {
objects = append(objects, GraphicObject{Type: "TextObject", TextObject: object})
}
for _, object := range annotation.Appearance.PathObject {
objects = append(objects, GraphicObject{Type: "PathObject", PathObject: object})
}
for _, object := range annotation.Appearance.ImageObject {
objects = append(objects, GraphicObject{Type: "ImageObject", ImageObject: object})
}
if len(annotation.Appearance.CompositeGraphicUnit) != 0 {
return fmt.Errorf("use composite copy to insert existing complex content")
}
}
var content []byte
for _, object := range objects {
prepared, err := next.prepareObject(next.nextID(), cloneEditorData(object))
if err != nil {
return err
}
data, err := editorObjectXML(prepared)
if err != nil {
return err
}
content = append(content, bytes.TrimPrefix(data, []byte(xml.Header))...)
}
appearance, err := editorXMLContainer("Appearance", ofdAttrs{{Name: xml.Name{Local: "Boundary"}, Value: annotation.Appearance.Boundary}}, content)
if err != nil {
return err
}
attrs := ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: id}, {Name: xml.Name{Local: "Type"}, Value: annotation.Type},
{Name: xml.Name{Local: "Creator"}, Value: annotation.Creator}, {Name: xml.Name{Local: "LastModDate"}, Value: time.Now().Format("2006-01-02")}}
attrs.add("Subtype", annotation.Subtype)
attrs.flag("Visible", annotation.Visible)
if annotation.NoZoom {
attrs.add("NoZoom", "true")
}
if annotation.NoRotate {
attrs.add("NoRotate", "true")
}
content = nil
if annotation.Remark != "" {
content = editorXMLText("Remark", annotation.Remark)
}
content = append(content, bytes.TrimPrefix(appearance, []byte(xml.Header))...)
data, err := editorXMLContainer("Annot", attrs, content)
if err != nil {
return err
}
return next.appendAnnotations(index, data)
})
if err != nil {
return "", err
}
return id, nil
}
// Annotation 获取注解的独立内容快照,修改原注解应使用局部编辑方法以保留扩展字段
// 入参: index 页面索引, id 注解标识
// 返回: Annotation 注解内容, error 错误信息
func (e *Editor) Annotation(index int, id string) (Annotation, error) {
data, err := e.annotationXML(index, id)
var annotation Annotation
if err == nil {
err = xml.Unmarshal(data, &annotation)
}
return annotation, err
}
// annotationXML 读取唯一注解的独立原文,不以类型、大小或可见性筛选
// 入参: index 页面索引, id 注解标识
// 返回: []byte 注解XML, error 错误信息
func (e *Editor) annotationXML(index int, id string) ([]byte, error) {
if index < 0 || index >= len(e.pages) {
return nil, fmt.Errorf("page index %d out of range", index)
}
if e.source == nil || e.source.document.Annotations == "" {
return nil, fmt.Errorf("page has no annotations")
}
reader := e.source.reader
name := reader.ResPath(e.source.document.Annotations)
data, err := reader.readFile(name)
if err != nil {
return nil, err
}
root, err := parseEditorXML(data)
if err != nil {
return nil, err
}
var result []byte
for _, ref := range root.children {
if ref.name.Space != root.name.Space || ref.name.Local != "Page" || ref.attr("PageID") != e.pages[index].ID || ref.child("FileLoc") == nil {
continue
}
file, err := editorPageLocation(reader, nil, name, strings.TrimSpace(editorImportText(data, ref.child("FileLoc"))))
if err != nil {
return nil, err
}
content, err := reader.readFile(file)
if err != nil {
return nil, err
}
page, err := parseEditorXML(content)
if err != nil {
return nil, err
}
for _, node := range page.children {
if node.name.Local != "Annot" || node.name.Space != page.name.Space || node.attr("ID") != id {
continue
}
if result != nil {
return nil, fmt.Errorf("ambiguous annotation ID %q", id)
}
result, err = editorXMLStandalone(content[node.start:node.end], node)
if err != nil {
return nil, err
}
}
}
if result == nil {
return nil, fmt.Errorf("annotation %q not found", id)
}
return result, nil
}
// appendAnnotations 在独立页面注解文件末尾追加原文,保留原索引及未知内容
// 入参: index 页面索引, annotations 标准注解XML片段
// 返回: error 错误信息
func (e *Editor) appendAnnotations(index int, annotations []byte) error {
base := e.source
var err error
if base == nil {
base, err = e.importBase()
if err != nil {
return err
}
}
reader := base.reader
name := reader.ResPath(base.document.Annotations)
var data []byte
if base.document.Annotations == "" {
name = path.Join(base.directory, "Annotations.xml")
data, err = editorXMLContainer("Annotations", nil, nil)
} else {
if file, ok := reader.packageFile(name); ok {
name = cleanPackagePath(file.Name)
}
data, err = reader.readFile(name)
}
if err != nil {
return err
}
root, err := parseEditorXML(data)
if err != nil {
return err
}
var ref *editorXML
var existing string
content, err := editorXMLContainer("PageAnnot", nil, nil)
if err != nil {
return err
}
for _, item := range root.children {
if item.name.Local == "Page" && item.name.Space == root.name.Space && item.attr("PageID") == e.pages[index].ID {
ref = item
loc := item.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
}
existing = file
break
}
}
page, err := parseEditorXML(content)
if err != nil {
return err
}
added := bytes.TrimPrefix(annotations, []byte(xml.Header))
if page.open == page.end {
content = editorPatchXML(content, []editorXMLPatch{editorXMLContent(content, page, added)})
} else {
content = editorPatchXML(content, []editorXMLPatch{{page.close, page.close, added}})
}
file := existing
if file != "" {
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 == existing {
file = ""
break
}
}
}
if file == "" {
file = path.Join(base.directory, "Annotations", e.nextID()+".xml")
}
if ref != nil {
loc := ref.child("FileLoc")
var value bytes.Buffer
_ = xml.EscapeText(&value, []byte("/"+file))
data = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, loc, value.Bytes())})
} else {
entry, err := editorXMLContainer("Page", ofdAttrs{{Name: xml.Name{Local: "PageID"}, Value: e.pages[index].ID}}, editorXMLText("FileLoc", "/"+file))
if err != nil {
return err
}
entry = bytes.TrimPrefix(entry, []byte(xml.Header))
if root.open == root.end {
data = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, root, entry)})
} else {
data = editorPatchXML(data, []editorXMLPatch{{root.close, root.close, entry}})
}
}
parts := map[string][]byte{name: data, file: content}
if base.document.Annotations == "" {
docName := cleanPackagePath(reader.OFD.DocBody[0].DocRoot)
docData, err := reader.readFile(docName)
if err != nil {
return err
}
doc, err := parseEditorXML(docData)
if err != nil {
return err
}
position := doc.close
for _, child := range doc.children {
if slices.Contains([]string{"CustomTags", "Attachments", "Extensions"}, child.name.Local) {
position = child.start
break
}
}
parts[docName] = editorPatchXML(docData, []editorXMLPatch{{position, position, editorXMLText("Annotations", "/"+name)}})
}
if err := e.commitAnnotationParts(base, parts); err != nil {
return err
}
pages := maps.Clone(e.source.annotationPages)
if pages == nil {
pages = make(map[string]bool)
}
pages[e.pages[index].ID] = true
e.source.annotationPages = pages
return nil
}
// annotationFiles 获取仍被引用的注解文件,保留原文件中的未知页引用
// 返回: []string 文件路径
func (e *Editor) annotationFiles() []string {
removed := make(map[string]bool)
for id := range e.source.pages {
removed[id] = true
}
for id := range e.source.annotationPages {
removed[id] = true
}
for _, page := range e.pages {
delete(removed, page.ID)
}
var files []string
for name, pages := range e.source.reader.annotationFiles {
for _, page := range pages {
if !removed[page] {
files = append(files, name)
break
}
}
}
return files
}
// commitAnnotationParts 提交独立注解包快照,新建文档复用导入包基底
// 入参: base 原包, parts 修改的文件
// 返回: error 错误信息
func (e *Editor) commitAnnotationParts(base *editorSource, parts map[string][]byte) error {
files := maps.Clone(base.reader.files)
if files == nil {
files = make(map[string][]byte)
}
maps.Copy(files, parts)
reader := &Reader{Zip: base.reader.Zip, files: files}
if err := reader.initRoot(); err != nil {
return err
}
doc, err := reader.Doc()
if err != nil {
return err
}
before, after := e.source, *base
after.reader, after.document = reader, doc
if before == nil {
for i := range e.resources {
resource := &e.resources[i]
if resource.font != nil {
resource.font = cloneEditorData(resource.font)
resource.font.FontFile = "/" + resource.name
}
if resource.image != nil {
resource.image = cloneEditorData(resource.image)
resource.image.MediaFile = "/" + resource.name
}
}
}
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
}
// annotationScope 将注解外观作为内部编辑根容器,保留标准页块结构和坐标约定
// 入参: page 页面索引, path 注解内部路径
// 返回: *Reader 预览, *Renderer 度量器, *editorCompositeNode 根, []*editorCompositeNode 成员, error 错误信息
func (e *Editor) annotationScope(page int, path ObjectPath) (*Reader, *Renderer, *editorCompositeNode, []*editorCompositeNode, error) {
data, err := e.annotationXML(page, path.Annotation)
if err != nil {
return nil, nil, nil, nil, err
}
annotation, err := parseEditorXML(data)
if err != nil {
return nil, nil, nil, nil, err
}
appearance := annotation.child("Appearance")
if appearance == nil {
return nil, nil, nil, nil, fmt.Errorf("annotation has no appearance")
}
data, err = editorXMLStandalone(data[appearance.start:appearance.end], appearance)
if err != nil {
return nil, nil, nil, nil, err
}
root, err := newEditorCompositeNode(data)
if err != nil {
return nil, nil, nil, nil, err
}
root.setStates(e.source.annotationStates[e.pages[page].ID+"/"+path.Annotation])
reader, err := e.Reader()
if err != nil {
return nil, nil, nil, nil, err
}
if _, err = reader.PageContentByIndex(page); err != nil {
reader.Close()
return nil, nil, nil, nil, err
}
renderer := NewRenderer(reader, WithFontFS(e.fontFS...))
root.parent, root.visible = IdentityMatrix, true
scope := root
for depth := 0; ; depth++ {
members, loadErr := e.compositeMembers(scope, reader, renderer, make(map[string]bool))
if loadErr != nil {
err = loadErr
break
}
if depth == len(path.Children) {
for i, member := range members {
member.index = i
}
return reader, renderer, root, members, nil
}
i := path.Children[depth]
if i < 0 || i >= len(members) {
err = fmt.Errorf("annotation member index %d out of range", i)
break
}
scope = members[i]
}
reader.Close()
return nil, nil, nil, nil, err
}
// writeAnnotationScope 只替换外观片段并保存会话排版状态,不改写注解元数据和动作
// 入参: page 页面索引, id 注解标识, data 外观XML, states 内部编辑状态
// 返回: error 错误信息
func (e *Editor) writeAnnotationScope(page int, id string, data []byte, states map[string]editorCompositeState) error {
revision := e.Revision()
err := e.editAnnotations(page, []string{id}, func(original []byte, node *editorXML) ([]byte, error) {
fragment, err := editorXMLStandalone(original[node.start:node.end], node)
if err != nil {
return nil, err
}
root, err := parseEditorXML(fragment)
if err != nil {
return nil, err
}
appearance := root.child("Appearance")
return editorAnnotationDate(editorPatchXML(fragment, []editorXMLPatch{{appearance.start, appearance.end, bytes.TrimPrefix(data, []byte(xml.Header))}}))
})
if err == nil && revision != e.Revision() {
statesByID := maps.Clone(e.source.annotationStates)
if statesByID == nil {
statesByID = make(map[string]map[string]editorCompositeState)
}
statesByID[e.pages[page].ID+"/"+id] = maps.Clone(states)
e.source.annotationStates = statesByID
}
return err
}
+283
View File
@@ -0,0 +1,283 @@
// 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"
"slices"
)
// AnnotationSelection 保存当前编辑器内可重复粘贴的注解原文及资源上下文
// 来源修改或删除不影响快照,不用于跨文档传输
type AnnotationSelection struct {
editor *Editor
data [][]byte
resources []string
states map[string]map[string]editorCompositeState
}
// CaptureAnnotations 捕获注解选区,保留未知字段、外观及动作
// 入参: page 页面索引, ids 不重复的注解标识
// 返回: *AnnotationSelection 独立快照, error 错误信息
func (e *Editor) CaptureAnnotations(page int, ids []string) (*AnnotationSelection, error) {
content, err := e.page(page)
if err != nil {
return nil, err
}
selection := &AnnotationSelection{editor: e, states: make(map[string]map[string]editorCompositeState)}
seen := make(map[string]bool)
for _, id := range ids {
if seen[id] {
return nil, fmt.Errorf("duplicate annotation ID %q", id)
}
seen[id] = true
data, err := e.annotationXML(page, id)
if err != nil {
return nil, err
}
root, err := parseEditorXML(data)
if err != nil {
return nil, err
}
if !editorXMLCopyable(root) {
return nil, fmt.Errorf("annotation contains unsupported identifiers")
}
selection.data = append(selection.data, data)
selection.states[id] = maps.Clone(e.source.annotationStates[e.pages[page].ID+"/"+id])
}
if e.originalPage(page) {
origin := e.source.pages[e.pages[page].ID]
for _, name := range content.PageRes {
selection.resources = append(selection.resources, e.source.reader.ResPath(resolveResourcePath(origin.ref.BaseLoc, "", name)))
}
}
return selection, nil
}
// PasteAnnotations 将注解快照粘贴到指定页并平移,保留动作目标和原有外观
// 全部标识重新分配,页级资源提升为文档资源,一次操作计入一条撤销记录
// 入参: page 目标页面索引, selection 当前编辑器快照, dx、dy 页面毫米位移
// 返回: []string 新注解标识, error 错误信息
func (e *Editor) PasteAnnotations(page int, selection *AnnotationSelection, dx, dy float64) ([]string, error) {
if selection == nil || selection.editor != e || !finite(dx) || !finite(dy) {
return nil, fmt.Errorf("invalid annotation selection or offset")
}
var result []string
err := e.Transaction(func(edit *Editor) error {
if page < 0 || page >= len(edit.pages) {
return fmt.Errorf("page index %d out of range", page)
}
if len(selection.data) == 0 {
return nil
}
if err := edit.prepareSourceIDs(); err != nil {
return err
}
var content []byte
var states map[string]map[string]editorCompositeState
if edit.source != nil {
states = maps.Clone(edit.source.annotationStates)
}
if states == nil {
states = make(map[string]map[string]editorCompositeState)
}
for _, data := range selection.data {
root, err := parseEditorXML(data)
if err != nil {
return err
}
ids := make(map[string]string)
if err := edit.collectCompositeIDs(root, ids); err != nil {
return err
}
id := ids[editorResourceID(root.attr("ID"))]
states[edit.pages[page].ID+"/"+id] = remapCompositeStates(selection.states[root.attr("ID")], ids)
data, err = editorXMLRemapIDs(data, root, ids)
if err != nil {
return err
}
root, err = parseEditorXML(data)
if err != nil {
return err
}
appearance := root.child("Appearance")
if appearance == nil {
return fmt.Errorf("annotation has no appearance")
}
box, err := ParseBox(appearance.attr("Boundary"))
if err != nil || !finite(box.X+dx) || !finite(box.Y+dy) {
return fmt.Errorf("invalid annotation boundary")
}
box.X, box.Y = box.X+dx, box.Y+dy
fragment, err := editorXMLAttribute(data, appearance, "Boundary", editorBoxString(box))
if err != nil {
return err
}
data, err = editorAnnotationDate(editorPatchXML(data, []editorXMLPatch{{appearance.start, appearance.end, fragment}}))
if err != nil {
return err
}
content = append(content, bytes.TrimPrefix(data, []byte(xml.Header))...)
result = append(result, id)
}
if err := edit.appendAnnotations(page, content); err != nil {
return err
}
edit.source.annotationStates = states
if len(selection.resources) != 0 {
reader := edit.source.reader
name := cleanPackagePath(reader.OFD.DocBody[0].DocRoot)
data, err := reader.readFile(name)
if err != nil {
return err
}
root, err := parseEditorXML(data)
if err != nil {
return err
}
common := root.child("CommonData")
var added []byte
for _, name := range selection.resources {
if !slices.ContainsFunc(append(slices.Clone(edit.source.document.CommonData.PublicRes), edit.source.document.CommonData.DocumentRes...), func(value string) bool { return reader.ResPath(value) == name }) {
added = append(added, editorXMLText("DocumentRes", "/"+name)...)
}
}
if len(added) != 0 {
position := common.close
for _, node := range common.children {
if node.name.Local == "TemplatePage" || node.name.Local == "DefaultCS" {
position = node.start
break
}
}
return edit.commitAnnotationParts(edit.source, map[string][]byte{name: editorPatchXML(data, []editorXMLPatch{{position, position, added}})})
}
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
// TransformAnnotations 对注解选区施加页面仿射变换,保持内部图元与外观边界同步
// 入参: page 页面索引, ids 注解标识, matrix 可逆页面变换
// 返回: error 错误信息
func (e *Editor) TransformAnnotations(page int, ids []string, matrix Matrix) error {
if _, ok := matrix.Invert(); !ok || !finite(matrix.a) || !finite(matrix.b) || !finite(matrix.c) || !finite(matrix.d) || !finite(matrix.e) || !finite(matrix.f) {
return fmt.Errorf("annotation transform must be finite and invertible")
}
return e.Transaction(func(edit *Editor) error {
seen := make(map[string]bool)
for _, id := range ids {
if seen[id] {
return fmt.Errorf("duplicate annotation ID %q", id)
}
seen[id] = true
annotation, err := edit.Annotation(page, id)
if err != nil {
return err
}
if matrix == IdentityMatrix {
continue
}
box, err := ParseBox(annotation.Appearance.Boundary)
if err != nil {
return err
}
bounds := matrix.TransformBox(box)
path := ObjectPath{Annotation: id}
reader, _, _, members, err := edit.compositeScope(page, path)
if err != nil {
return err
}
reader.Close()
indexes := make([]int, len(members))
for i := range indexes {
indexes[i] = i
}
local := TranslationMatrix(box.X-bounds.X, box.Y-bounds.Y).Multiply(matrix)
if err := edit.changeCompositeObjects(page, path, indexes, func(Box) Matrix { return local }); err != nil {
return err
}
if err := edit.editAnnotations(page, []string{id}, func(data []byte, node *editorXML) ([]byte, error) {
fragment, err := editorXMLStandalone(data[node.start:node.end], node)
if err != nil {
return nil, err
}
root, err := parseEditorXML(fragment)
if err != nil {
return nil, err
}
appearance := root.child("Appearance")
updated, err := editorXMLAttribute(fragment, appearance, "Boundary", editorBoxString(bounds))
if err != nil {
return nil, err
}
return editorAnnotationDate(editorPatchXML(fragment, []editorXMLPatch{{appearance.start, appearance.end, updated}}))
}); err != nil {
return err
}
}
return nil
})
}
// ReplaceAnnotationText 修改注解内部匹配的普通文字,保留各成员位置、旋转及未修改属性
// 入参: page 页面索引, id 注解标识, old 原文字, value 新文字, style 文字样式增量
// 返回: int 修改数量, error 错误信息
func (e *Editor) ReplaceAnnotationText(page int, id, old, value string, style TextStyle) (int, error) {
count := 0
err := e.Transaction(func(edit *Editor) error {
return edit.editCompositeScope(page, ObjectPath{Annotation: id}, func(renderer *Renderer, _ *editorCompositeNode, nodes []*editorCompositeNode) error {
visiting := make(map[string]bool)
var replace func([]*editorCompositeNode) error
replace = func(nodes []*editorCompositeNode) error {
for i, member := range edit.measureCompositeMembers(renderer, nodes) {
if member.Object.Type == "TextObject" && member.Object.TextObject.Text() == old {
if err := edit.updateCompositeText(nodes[i], member, &value, style, nil, nil); err != nil {
return err
}
count++
} else if member.Object.Type == "CompositeObject" || member.Object.Type == "CompositeGraphicUnit" {
resource := member.Object.CompositeGraphicUnit.ResourceID
if resource != "" && visiting[resource] {
return fmt.Errorf("cyclic composite resource %q", resource)
}
children, err := edit.compositeMembers(nodes[i], renderer.Reader, renderer, visiting)
if err != nil {
return err
}
visiting[resource] = true
if err := replace(children); err != nil {
return err
}
delete(visiting, resource)
}
}
return nil
}
return replace(nodes)
})
})
if err != nil {
return 0, err
}
return count, nil
}
+12 -1
View File
@@ -27,10 +27,12 @@ import (
"github.com/tdewolff/canvas" "github.com/tdewolff/canvas"
) )
// ObjectPath 定位页面顶层对象及复合对象内的成员,Children为从0开始的逐层绘制序号 // ObjectPath 定位页面顶层对象或注解外观内的成员,Children为从0开始的逐层绘制序号
// Annotation非空时定位该注解的Appearance,ID留空
// 路径不依赖资源标识,几何变换、资源隔离及撤销重做不改变路径;成员增删或排序后需重新枚举 // 路径不依赖资源标识,几何变换、资源隔离及撤销重做不改变路径;成员增删或排序后需重新枚举
type ObjectPath struct { type ObjectPath struct {
ID string `json:"id"` ID string `json:"id"`
Annotation string `json:"annotation,omitempty"`
Children []int `json:"children,omitempty"` Children []int `json:"children,omitempty"`
} }
@@ -294,6 +296,9 @@ func compositeOrientation(box Box, matrix Matrix) Matrix {
// 入参: page 页面索引, path 复合对象路径 // 入参: page 页面索引, path 复合对象路径
// 返回: *Reader 预览读取器, *Renderer 度量器, *editorCompositeNode 顶层节点, []*editorCompositeNode 成员, error 错误信息 // 返回: *Reader 预览读取器, *Renderer 度量器, *editorCompositeNode 顶层节点, []*editorCompositeNode 成员, error 错误信息
func (e *Editor) compositeScope(page int, path ObjectPath) (*Reader, *Renderer, *editorCompositeNode, []*editorCompositeNode, error) { func (e *Editor) compositeScope(page int, path ObjectPath) (*Reader, *Renderer, *editorCompositeNode, []*editorCompositeNode, error) {
if path.Annotation != "" {
return e.annotationScope(page, path)
}
layer, index, err := e.findObject(page, path.ID) layer, index, err := e.findObject(page, path.ID)
if err != nil { if err != nil {
return nil, nil, nil, nil, err return nil, nil, nil, nil, err
@@ -361,6 +366,9 @@ func newEditorCompositeNode(data []byte) (*editorCompositeNode, error) {
var object GraphicObject var object GraphicObject
object.Type = node.name.Local object.Type = node.name.Local
switch object.Type { switch object.Type {
case "Appearance":
object.Type = "CompositeObject"
err = xml.Unmarshal(data, &object.CompositeGraphicUnit)
case "TextObject": case "TextObject":
err = xml.Unmarshal(data, &object.TextObject) err = xml.Unmarshal(data, &object.TextObject)
case "PathObject": case "PathObject":
@@ -772,6 +780,9 @@ func (e *Editor) editCompositeScope(page int, path ObjectPath, edit func(*Render
if err != nil || !changed { if err != nil || !changed {
return err return err
} }
if path.Annotation != "" {
return e.writeAnnotationScope(page, path.Annotation, data, root.states)
}
next, err := newEditorCompositeNode(data) next, err := newEditorCompositeNode(data)
if err != nil { if err != nil {
return err return err
+7 -2
View File
@@ -278,7 +278,12 @@ func (e *Editor) CaptureCompositeObjects(page int, path ObjectPath, indexes []in
slices.Sort(indexes) slices.Sort(indexes)
var owner *editorCompositeNode var owner *editorCompositeNode
var container *editorXML var container *editorXML
selection := &CompositeSelection{editor: e, page: e.pages[page].ID, path: ObjectPath{ID: path.ID, Children: slices.Clone(path.Children)}, source: e.objectOrigin(path.ID).page} selection := &CompositeSelection{editor: e, page: e.pages[page].ID, path: ObjectPath{ID: path.ID, Annotation: path.Annotation, Children: slices.Clone(path.Children)}}
if path.Annotation == "" {
selection.source = e.objectOrigin(path.ID).page
} else if e.source != nil {
selection.source = e.source.pages[e.pages[page].ID]
}
for j, i := range indexes { for j, i := range indexes {
if i < 0 || i >= len(nodes) || j > 0 && indexes[j-1] == i { if i < 0 || i >= len(nodes) || j > 0 && indexes[j-1] == i {
return nil, fmt.Errorf("invalid composite copy selection") return nil, fmt.Errorf("invalid composite copy selection")
@@ -324,7 +329,7 @@ func (e *Editor) PasteCompositeObjects(page int, path ObjectPath, selection *Com
if _, err := e.page(page); err != nil { if _, err := e.page(page); err != nil {
return nil, err return nil, err
} }
if e.pages[page].ID != selection.page || path.ID != selection.path.ID || !slices.Equal(path.Children, selection.path.Children) { if e.pages[page].ID != selection.page || path.ID != selection.path.ID || path.Annotation != selection.path.Annotation || !slices.Equal(path.Children, selection.path.Children) {
var result []int var result []int
err := e.pasteCompositeSelection(selection, func(objects []GraphicObject) error { err := e.pasteCompositeSelection(selection, func(objects []GraphicObject) error {
var err error var err error
+6
View File
@@ -138,6 +138,12 @@ func (e *Editor) CopyObjectsToComposite(page int, path ObjectPath, objects []Gra
} else { } else {
owner.patches = append(owner.patches, editorXMLPatch{container.close, container.close, content}) owner.patches = append(owner.patches, editorXMLPatch{container.close, container.close, content})
} }
} else if owner.node.name.Local == "Appearance" {
if owner.node.open == owner.node.end {
owner.patches = append(owner.patches, editorXMLContent(owner.data, owner.node, content))
} else {
owner.patches = append(owner.patches, editorXMLPatch{owner.node.close, owner.node.close, content})
}
} else { } else {
content, err := editorXMLContainer("Content", nil, content) content, err := editorXMLContainer("Content", nil, content)
if err != nil { if err != nil {
+10
View File
@@ -201,6 +201,16 @@ func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error)
} }
refs := editorResourceRefs{ids: make(map[string]bool), files: make(map[string]bool)} refs := editorResourceRefs{ids: make(map[string]bool), files: make(map[string]bool)}
composite := false composite := false
if e.source != nil {
for _, name := range e.annotationFiles() {
if data, changed := e.source.reader.files[name]; changed {
composite = true
if _, safe := refs.scan(bytes.NewReader(data), name); !safe {
return nil, nil
}
}
}
}
total := len(e.pages) + len(e.resources) total := len(e.pages) + len(e.resources)
for i, page := range e.pages { for i, page := range e.pages {
if err := progress.report("fonts", i, total); err != nil { if err := progress.report("fonts", i, total); err != nil {
+68 -1
View File
@@ -14,7 +14,74 @@
package ofdgo package ofdgo
import "slices" import (
"maps"
"slices"
)
// Transaction 将多项修改作为一次原子操作提交,失败时保留原文档及历史
// 回调仅操作传入的编辑器,不可保留该实例供后续使用
// 入参: edit 批量修改回调
// 返回: error 修改错误
func (e *Editor) Transaction(edit func(*Editor) error) error {
before := e.transactionSnapshot()
next := e.transactionSnapshot()
next.history, next.historyIndex, next.historyLimit = nil, 0, 0
*e = next
if err := edit(e); err != nil {
*e = before
return err
}
next = e.transactionSnapshot()
*e = before
changed := next.revision != before.revision
e.restoreTransaction(next)
if changed {
e.serial, e.revision = before.serial, before.revision
if change := e.recordChange(); change != nil {
after := e.transactionSnapshot()
before.history, after.history = nil, nil
change.undo = func(e *Editor) { e.restoreTransaction(before) }
change.redo = func(e *Editor) { e.restoreTransaction(after) }
}
}
return nil
}
// transactionSnapshot 复制可变容器,保留只读XML、图元及二进制资源的共享
// 返回: Editor 独立事务状态
func (e *Editor) transactionSnapshot() Editor {
next := *e
next.Info = cloneEditorData(e.Info)
next.pages = make([]PageContent, len(e.pages))
for i, page := range e.pages {
next.pages[i] = copyEditorPage(page)
}
next.resources = slices.Clone(e.resources)
next.fonts, next.images, next.resourceID = maps.Clone(e.fonts), maps.Clone(e.images), maps.Clone(e.resourceID)
next.origins = maps.Clone(e.origins)
if e.source != nil {
source := *e.source
source.pages, source.origins = maps.Clone(source.pages), maps.Clone(source.origins)
next.source = &source
}
return next
}
// restoreTransaction 恢复文档状态并保留历史容器、共享资源和递增标识
// 入参: state 文档状态
func (e *Editor) restoreTransaction(state Editor) {
state = state.transactionSnapshot()
if len(e.resources) > len(state.resources) {
state.resources = append(state.resources, e.resources[len(state.resources):]...)
}
maps.Copy(state.fonts, e.fonts)
maps.Copy(state.images, e.images)
maps.Copy(state.resourceID, e.resourceID)
state.history, state.historyIndex, state.historyLimit = e.history, e.historyIndex, e.historyLimit
state.serial, state.maxID = max(e.serial, state.serial), max(e.maxID, state.maxID)
*e = state
}
// editorChange 页面或对象操作及其修订标识 // editorChange 页面或对象操作及其修订标识
type editorChange struct { type editorChange struct {
+250
View File
@@ -0,0 +1,250 @@
// 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"
"slices"
)
// AnnotationLink 表示外部地址或文档内目标,两者必须且只能指定一项
type AnnotationLink struct {
URI *URI
Dest *Dest
}
// AnnotationLink 读取注解中唯一的点击跳转,书签目标保持原始动作
// 入参: page 页面索引, id 注解标识
// 返回: Action 跳转动作, error 错误信息
func (e *Editor) AnnotationLink(page int, id string) (Action, error) {
data, err := e.annotationXML(page, id)
if err != nil {
return Action{}, err
}
root, err := parseEditorXML(data)
if err != nil {
return Action{}, err
}
targets, _ := annotationLinkNodes(root)
if len(targets) != 1 {
return Action{}, fmt.Errorf("annotation does not contain one link target")
}
var action Action
node := targets[0].parent
err = xml.Unmarshal(data[node.start:node.end], &action)
return action, err
}
// annotationLinkNodes 获取外观内的点击跳转和基本对象,不进入私有扩展
// 入参: root 注解根节点
// 返回: []*editorXML 跳转目标, []*editorXML 基本对象
func annotationLinkNodes(root *editorXML) ([]*editorXML, []*editorXML) {
var targets, objects []*editorXML
var walk func(*editorXML)
walk = func(n *editorXML) {
if n.name.Space != root.name.Space {
return
}
if n.name.Local == "Action" && n.attr("Event") == "CLICK" {
for _, child := range n.children {
if child.name.Space == n.name.Space && (child.name.Local == "URI" || child.name.Local == "Goto") {
targets = append(targets, child)
}
}
}
if slices.Contains([]string{"TextObject", "PathObject", "ImageObject"}, n.name.Local) {
objects = append(objects, n)
}
for _, child := range n.children {
walk(child)
}
}
if appearance := root.child("Appearance"); appearance != nil {
walk(appearance)
}
return targets, objects
}
// AddLinkAnnotation 新增标准链接注解,区域位于页面毫米坐标,不检查目标是否可达
// 入参: page 页面索引, box 点击区域, target 链接目标, creator 作者
// 返回: string 注解标识, error 错误信息
func (e *Editor) AddLinkAnnotation(page int, box Box, target AnnotationLink, creator string) (string, error) {
var id string
err := e.Transaction(func(edit *Editor) error {
if _, err := annotationLinkXML(target); err != nil {
return err
}
object, err := NewShape(ShapeRectangle, Box{W: box.W, H: box.H})
if err != nil {
return err
}
no := false
object.Fill, object.Stroke = &no, &no
id, err = edit.AddAnnotation(page, Annotation{Type: "Link", Creator: creator, Appearance: Appearance{
Boundary: editorBoxString(box), Objects: []GraphicObject{{Type: "PathObject", PathObject: object}},
}})
if err != nil {
return err
}
return edit.UpdateAnnotationLink(page, id, target)
})
if err != nil {
return "", err
}
return id, nil
}
// UpdateAnnotationLink 修改注解中唯一的点击跳转,保留动作区域、其他动作及扩展属性
// 无跳转时仅允许在单个基本对象上添加,多个跳转不隐式合并
// 入参: page 页面索引, id 注解标识, target 新目标,不验证目标可达性
// 返回: error 错误信息
func (e *Editor) UpdateAnnotationLink(page int, id string, target AnnotationLink) error {
link, err := annotationLinkXML(target)
if err != nil {
return err
}
return e.editAnnotations(page, []string{id}, func(data []byte, node *editorXML) ([]byte, error) {
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
}
targets, objects := annotationLinkNodes(root)
if len(targets) > 1 {
return nil, fmt.Errorf("annotation contains multiple link targets")
}
if len(targets) == 1 {
n := targets[0]
updated := link
if n.name.Local == "URI" && target.URI != nil {
var old URI
if err := xml.Unmarshal(data[n.start:n.end], &old); err != nil {
return nil, err
}
before, err := annotationLinkXML(AnnotationLink{URI: &old})
if err != nil {
return nil, err
}
updated, err = editorXMLMerge(data, n, before, link)
if err != nil {
return nil, err
}
} else if n.name.Local == "Goto" && target.Dest != nil && n.child("Dest") != nil {
var old Goto
if err := xml.Unmarshal(data[n.start:n.end], &old); err != nil {
return nil, err
}
before, err := annotationLinkXML(AnnotationLink{Dest: old.Dest})
if err != nil {
return nil, err
}
updated, err = editorXMLMerge(data, n, before, link)
if err != nil {
return nil, err
}
} else {
var check func(*editorXML) bool
check = func(node *editorXML) bool {
allowed := map[string]string{"URI": "URI Base", "Goto": "", "Dest": "Type PageID Left Right Top Bottom Zoom", "Bookmark": "Name"}
attrs, known := allowed[node.name.Local]
if !known || node.name.Space != root.name.Space || !editorXMLAttributes(node, attrs) {
return false
}
for _, attr := range node.attrs {
if attr.Name.Space != "" && attr.Name.Space != "xmlns" {
return false
}
}
for _, child := range node.children {
if !check(child) {
return false
}
}
return true
}
if !check(n) {
return nil, fmt.Errorf("changing link type would discard extension data")
}
}
data = editorPatchXML(data, []editorXMLPatch{{n.start, n.end, updated}})
} else {
if len(objects) != 1 {
return nil, fmt.Errorf("a new link requires one appearance object")
}
action, err := editorXMLContainer("Action", ofdAttrs{{Name: xml.Name{Local: "Event"}, Value: "CLICK"}}, link)
if err != nil {
return nil, err
}
action = bytes.TrimPrefix(action, []byte(xml.Header))
if actions := objects[0].child("Actions"); actions != nil {
data = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, actions, append(bytes.Clone(data[actions.open:actions.close]), action...))})
} else {
actions, err := editorXMLContainer("Actions", nil, action)
if err != nil {
return nil, err
}
position := objects[0].open
if clips := objects[0].child("Clips"); clips != nil {
position = clips.end
}
data = editorPatchXML(data, []editorXMLPatch{{position, position, bytes.TrimPrefix(actions, []byte(xml.Header))}})
}
}
return editorAnnotationDate(data)
})
}
// annotationLinkXML 序列化标准跳转目标,不改写其他动作或命名空间
// 入参: target 跳转目标
// 返回: []byte 独立XML片段, error 错误信息
func annotationLinkXML(target AnnotationLink) ([]byte, error) {
if (target.URI == nil) == (target.Dest == nil) {
return nil, fmt.Errorf("specify one URI or destination")
}
var data []byte
var err error
if target.URI != nil {
attrs := ofdAttrs{{Name: xml.Name{Local: "URI"}, Value: target.URI.URI}}
attrs.add("Base", target.URI.Base)
data, err = editorXMLContainer("URI", attrs, nil)
} else {
dest := target.Dest
if !slices.Contains([]string{"XYZ", "Fit", "FitH", "FitV", "FitR"}, dest.Type) {
return nil, fmt.Errorf("invalid destination type %q", dest.Type)
}
attrs := ofdAttrs{{Name: xml.Name{Local: "Type"}, Value: dest.Type}, {Name: xml.Name{Local: "PageID"}, Value: dest.PageID}}
for _, value := range []struct {
name string
value float64
}{{"Left", dest.Left}, {"Top", dest.Top}, {"Right", dest.Right}, {"Bottom", dest.Bottom}, {"Zoom", dest.Zoom}} {
if !finite(value.value) {
return nil, fmt.Errorf("destination coordinates must be finite")
}
if dest.Type == "XYZ" && slices.Contains([]string{"Left", "Top", "Zoom"}, value.name) || dest.Type == "FitH" && value.name == "Top" || dest.Type == "FitV" && value.name == "Left" || dest.Type == "FitR" && value.name != "Zoom" {
attrs.add(value.name, ofdNumber(value.value))
}
}
data, err = editorXMLContainer("Dest", attrs, nil)
if err == nil {
data, err = editorXMLContainer("Goto", nil, bytes.TrimPrefix(data, []byte(xml.Header)))
}
}
return bytes.TrimPrefix(data, []byte(xml.Header)), err
}
+2
View File
@@ -38,6 +38,8 @@ type editorSource struct {
pages map[string]*editorSourcePage pages map[string]*editorSourcePage
origins map[string]*editorObjectOrigin origins map[string]*editorObjectOrigin
idsReady bool idsReady bool
annotationStates map[string]map[string]editorCompositeState
annotationPages map[string]bool
} }
// editorObjectOrigin 保留原对象的XML语义,供复制和局部更新复用 // editorObjectOrigin 保留原对象的XML语义,供复制和局部更新复用
+3
View File
@@ -28,6 +28,9 @@ func (e *Editor) prunePageReferences(parts map[string][]byte) error {
for id := range e.source.pages { for id := range e.source.pages {
removed[id] = true removed[id] = true
} }
for id := range e.source.annotationPages {
removed[id] = true
}
for _, page := range e.pages { for _, page := range e.pages {
delete(removed, page.ID) delete(removed, page.ID)
} }
+16
View File
@@ -22,6 +22,22 @@ import (
"slices" "slices"
) )
// TransformObjectsMatrix 对同页对象施加可逆页面变换,保留原文、资源与裁剪
// 入参: page 页面索引, ids 对象标识, matrix 页面变换
// 返回: error 错误信息
func (e *Editor) TransformObjectsMatrix(page int, ids []string, matrix Matrix) error {
if _, ok := matrix.Invert(); !ok || !finite(matrix.a) || !finite(matrix.b) || !finite(matrix.c) || !finite(matrix.d) || !finite(matrix.e) || !finite(matrix.f) {
return fmt.Errorf("object transform must be finite and invertible")
}
objects, _, err := e.selectedObjects(page, ids)
if err != nil || len(objects) == 0 {
return err
}
return e.transformObjects(page, objects, func(object GraphicObject) (GraphicObject, error) {
return e.transformMatrix(object, matrix)
})
}
// RotateObject 绕对象可见范围中心旋转 // RotateObject 绕对象可见范围中心旋转
// 入参: page 页面索引, id 对象标识, degrees 顺时针角度 // 入参: page 页面索引, id 对象标识, degrees 顺时针角度
// 返回: error 错误信息 // 返回: error 错误信息
+101
View File
@@ -0,0 +1,101 @@
// 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"
"github.com/tdewolff/canvas"
)
// InkPoint 手写采样点,X和Y为毫米,Pressure为0到1的压力
type InkPoint struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Pressure float64 `json:"pressure"`
}
// NewInk 将采样点转为标准矢量路径,等宽笔保留中心线,压感笔使用一次填充的连续轮廓
// 不推测笔压、不修改输入;单点生成圆点,页面外路径由页面边界裁切
// 入参: points 采样点, width 等宽线宽或压感最大线宽,单位毫米, pressure 是否使用压力
// 返回: PathObject 黑色笔迹,可修改颜色和透明度后插入页面或注解, error 错误信息
func NewInk(points []InkPoint, width float64, pressure bool) (PathObject, error) {
if len(points) == 0 || !finite(width) || width <= 0 {
return PathObject{}, fmt.Errorf("ink requires points and a positive finite width")
}
path := &canvas.Path{}
stationary := true
for i, point := range points {
if !finite(point.X) || !finite(point.Y) || !finite(point.Pressure) || point.Pressure < 0 || point.Pressure > 1 {
return PathObject{}, fmt.Errorf("invalid ink point %d", i)
}
stationary = stationary && point.X == points[0].X && point.Y == points[0].Y
if !pressure {
if i == 0 {
path.MoveTo(point.X, point.Y)
} else {
path.LineTo(point.X, point.Y)
}
continue
}
radius := width * point.Pressure / 2
if radius > 0 {
path = path.Append(canvas.Circle(radius).Translate(point.X, point.Y))
}
if i == 0 {
continue
}
previous := points[i-1]
r := width * previous.Pressure / 2
dx, dy := point.X-previous.X, point.Y-previous.Y
length := math.Hypot(dx, dy)
if radius == 0 && r == 0 || length <= math.Abs(radius-r) {
continue
}
x, y := dx/length, dy/length
a := (r - radius) / length
b := math.Sqrt(1 - a*a)
nx, ny, mx, my := a*x-b*y, a*y+b*x, a*x+b*y, a*y-b*x
segment := &canvas.Path{}
segment.MoveTo(previous.X+r*mx, previous.Y+r*my)
segment.LineTo(point.X+radius*mx, point.Y+radius*my)
segment.LineTo(point.X+radius*nx, point.Y+radius*ny)
segment.LineTo(previous.X+r*nx, previous.Y+r*ny)
segment.Close()
path = path.Append(segment)
}
filled := pressure || stationary
if !pressure && stationary {
path = canvas.Circle(width/2).Translate(points[0].X, points[0].Y)
}
if path.Empty() {
return PathObject{}, fmt.Errorf("ink has no visible stroke")
}
box := path.Bounds()
padding := width / 2
boundary := Box{X: box.X0 - padding, Y: box.Y0 - padding, W: math.Max(box.W(), width) + width, H: math.Max(box.H(), width) + width}
object := editorClipPath(path.Translate(-boundary.X, -boundary.Y))
object.Boundary = editorBoxString(boundary)
stroke := !filled
object.Fill, object.Stroke = &filled, &stroke
object.Cap, object.Join, object.LineWidth = "Round", "Round", width
if filled {
object.FillColor = &FillColor{Value: "0 0 0"}
} else {
object.StrokeColor = &StrokeColor{Value: "0 0 0"}
}
return object, nil
}
+1
View File
@@ -45,6 +45,7 @@ type Reader struct {
doc *Document doc *Document
Stamps map[string][]Stamp Stamps map[string][]Stamp
Annots map[string][]Annotation Annots map[string][]Annotation
annotationFiles map[string][]string
fileIndex map[string]*zip.File fileIndex map[string]*zip.File
fileIndexFold map[string]*zip.File fileIndexFold map[string]*zip.File
files map[string][]byte files map[string][]byte
+29
View File
@@ -37,6 +37,35 @@ type ObjectContour struct {
EvenOdd bool `json:"evenOdd,omitempty"` EvenOdd bool `json:"evenOdd,omitempty"`
} }
// AnnotationGeometry 获取注解外观的实际内容轮廓,不将Appearance容器边界视为绘制内容
// 链接保留透明对象定义的交互区域,图片按几何范围度量而不解码透明像素
// 入参: annotation 注解
// 返回: Box 页面范围, []ObjectContour 命中轮廓, error 错误信息
func (r *Renderer) AnnotationGeometry(annotation Annotation) (Box, []ObjectContour, error) {
object := GraphicObject{Type: "CompositeObject", CompositeGraphicUnit: CompositeGraphicUnit{Boundary: annotation.Appearance.Boundary, Objects: annotation.Appearance.Objects}}
box, contours, err := r.ObjectGeometry(object, "")
if err != nil {
return box, contours, err
}
for _, source := range r.annotationActionSources([]Annotation{annotation}) {
for _, action := range source.Actions {
if action.Event != "CLICK" {
continue
}
region, path := actionLinkRegion(source, action)
if region.W <= 0 || region.H <= 0 {
continue
}
if path == "" {
path = canvas.Rectangle(region.W, region.H).Translate(region.X, region.Y).ToSVG()
}
box = unionTextBox(box, region)
contours = append(contours, ObjectContour{Path: path})
}
}
return box, contours, nil
}
// ObjectContours 获取路径、图片或复合对象的实际绘制区域,包含描边、虚线、变换及对象裁剪 // ObjectContours 获取路径、图片或复合对象的实际绘制区域,包含描边、虚线、变换及对象裁剪
// 图片不解码像素,底纹不展开图案单元;不包含页面边界或父级变换 // 图片不解码像素,底纹不展开图案单元;不包含页面边界或父级变换
// 入参: object 路径、图片或复合对象, drawParam 图层绘制参数标识 // 入参: object 路径、图片或复合对象, drawParam 图层绘制参数标识
+2 -2
View File
@@ -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 {
@@ -176,7 +176,7 @@ func (s *svgResourceRenderer) beginAnnotation(id string) bool {
if s.objects == nil { if s.objects == nil {
return false return false
} }
s.objectStack = append(s.objectStack, svgObjectGroup{}) s.objectStack = append(s.objectStack, svgObjectGroup{path: "annotation:" + id, composite: true})
fmt.Fprintf(s.writer, `<g data-ofd-object="annotation:%s">`, html.EscapeString(id)) fmt.Fprintf(s.writer, `<g data-ofd-object="annotation:%s">`, html.EscapeString(id))
return true return true
} }
+25
View File
@@ -109,6 +109,7 @@ func (e *Editor) WritePagesTo(writer io.Writer, indexes []int) (int64, error) {
if e.source != nil { if e.source != nil {
source := *e.source source := *e.source
source.pages = make(map[string]*editorSourcePage, len(indexes)) source.pages = make(map[string]*editorSourcePage, len(indexes))
source.annotationPages = nil
for _, index := range indexes { for _, index := range indexes {
id := e.pages[index].ID id := e.pages[index].ID
if page := e.source.pages[id]; page != nil { if page := e.source.pages[id]; page != nil {
@@ -324,6 +325,30 @@ func (e *Editor) usedResources() (fonts, images []editorResource, spaces []Color
used := make(map[string]bool) used := make(map[string]bool)
promoted := make(map[string]bool) promoted := make(map[string]bool)
files := make(map[string]bool) files := make(map[string]bool)
if e.source != nil {
for _, name := range e.annotationFiles() {
data, changed := e.source.reader.files[name]
if !changed {
continue
}
refs := editorResourceRefs{ids: used, files: make(map[string]bool)}
if _, safe := refs.scan(bytes.NewReader(data), name); !safe {
for _, resource := range e.resources {
if resource.font != nil {
used[resource.font.ID] = true
}
if resource.image != nil {
used[resource.image.ID] = true
}
if resource.space != nil {
used[resource.space.ID] = true
}
used[resource.definition()] = true
}
}
}
maps.Copy(promoted, used)
}
for _, page := range e.pages { for _, page := range e.pages {
var original map[string]GraphicObject var original map[string]GraphicObject
if e.source != nil { if e.source != nil {