mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 13:56:32 +08:00
This commit is contained in:
1 parent
ed11ade4e8
commit
a8346a6006
23 files changed
+2297
-104
No files matched your search
@@ -99,6 +99,8 @@
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</form>
|
||||
</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="注解">
|
||||
<form id="annotationForm" method="dialog">
|
||||
<div id="annotationDetail" class="annotation-detail"></div>
|
||||
|
||||
+21
-7
@@ -134,6 +134,24 @@ a:focus-visible {
|
||||
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 {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
@@ -1516,10 +1534,6 @@ button.outline-link:hover {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.edit-object.edit-background:not(.selected):not(.erasing) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.edit-object.edit-contour,
|
||||
.edit-object.edit-contour.selected,
|
||||
.edit-object.edit-contour.erasing {
|
||||
@@ -2310,16 +2324,16 @@ button.outline-link:hover {
|
||||
|
||||
@media (min-width: 901px) and (max-width: 1600px) and (pointer: fine) {
|
||||
.editor-group {
|
||||
padding-inline: 5px;
|
||||
padding-inline: 3px;
|
||||
column-gap: 3px;
|
||||
}
|
||||
|
||||
.editor-toolbar .button {
|
||||
padding-inline: 7px;
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
.editor-group:has(#textFont) {
|
||||
grid-template-columns: 144px 32px;
|
||||
grid-template-columns: 136px 32px;
|
||||
}
|
||||
|
||||
.editor-paragraph {
|
||||
|
||||
+203
-38
@@ -28,6 +28,7 @@ let textMeasure = null;
|
||||
const state = {
|
||||
composite: null,
|
||||
annotationEdit: null,
|
||||
annotationCreate: null,
|
||||
ready: false,
|
||||
wasmExited: false,
|
||||
wasmSeq: 0,
|
||||
@@ -335,6 +336,12 @@ const el = {
|
||||
signatureSummary: document.querySelector("#signatureSummary"),
|
||||
signatureList: document.querySelector("#signatureList"),
|
||||
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"),
|
||||
annotationFields: document.querySelector("#annotationFields"),
|
||||
annotationRemark: document.querySelector("#annotationRemark"),
|
||||
@@ -356,6 +363,7 @@ const fontManager = new FontManager({
|
||||
onPermissionChange: updateFontPermissionHint,
|
||||
});
|
||||
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());
|
||||
|
||||
const canvasEditor = new CanvasEditor(el.viewerPanel, {
|
||||
@@ -371,9 +379,7 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
|
||||
setStatus(reason || pageStatus(state.pageIndex, state.doc.pageCount));
|
||||
}
|
||||
},
|
||||
onTransform: (item, change) => (item.items || [item]).every(object => object.type === "Annotation")
|
||||
? changeAnnotations(item, "move", change.x, change.y)
|
||||
: changeDocument(item.items ? "ofdgoTransformObjects" : "ofdgoTransformObject", item,
|
||||
onTransform: (item, change) => changeDocument(item.items ? "ofdgoTransformObjects" : "ofdgoTransformObject", item,
|
||||
change.x + item.x * (1 - change.scale), change.y + item.y * (1 - change.scale), change.scale),
|
||||
onNudgeChange: () => {
|
||||
updatePendingChanges();
|
||||
@@ -389,13 +395,8 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
|
||||
syncSelection();
|
||||
if (state.fontRenderPending) window.setTimeout(refreshPendingFonts, 0);
|
||||
},
|
||||
onDelete: (item) => (item.items || [item]).every(object => object.type === "Annotation")
|
||||
? changeAnnotations(item, "delete") : changeDocument(item.items ? "ofdgoDeleteObjects" : "ofdgoDeleteObject", item),
|
||||
onDelete: (item) => changeDocument(item.items ? "ofdgoDeleteObjects" : "ofdgoDeleteObject", item),
|
||||
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"));
|
||||
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) };
|
||||
@@ -404,6 +405,7 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
|
||||
: changeDocument("ofdgoDeleteObjects", item);
|
||||
},
|
||||
onEdit: editCanvasObject,
|
||||
onPick: openObjectPicker,
|
||||
onExitScope: () => {
|
||||
if (!state.composite) return false;
|
||||
exitCompositeScope();
|
||||
@@ -414,6 +416,8 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
|
||||
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),
|
||||
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
|
||||
? 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),
|
||||
@@ -497,6 +501,86 @@ el.infoForm.addEventListener("submit", async event => {
|
||||
});
|
||||
el.annotationClose.addEventListener("click", () => el.annotationNote.close());
|
||||
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 => {
|
||||
if (!state.annotationEdit) return;
|
||||
event.preventDefault();
|
||||
@@ -1021,7 +1105,7 @@ function handleKeyDown(event) {
|
||||
|
||||
function formDialogOpen() {
|
||||
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) {
|
||||
@@ -1053,6 +1137,7 @@ function openObjectBounds() {
|
||||
}
|
||||
|
||||
function canCopyStyle(item) {
|
||||
if (item?.type === "Annotation") return false;
|
||||
if (!item?.scoped) return canEditObject(item, "update");
|
||||
if (item.type === "TextObject") return canEditObject(item, "replaceFont") && 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.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 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.placeholder = alpha === null ? "混合" : "";
|
||||
el.objectStrokeFields.hidden = !items.every(item => item.type === "PathObject" && (!item.scoped || canEditObject(item, "paint")));
|
||||
@@ -1393,21 +1478,96 @@ async function createDocument(event) {
|
||||
}
|
||||
}
|
||||
|
||||
function editAnnotationDetails(item) {
|
||||
state.annotationEdit = item;
|
||||
el.annotationRemark.value = item.remark || "";
|
||||
el.annotationCreator.value = item.creator || "";
|
||||
el.annotationFields.hidden = el.annotationSubmit.hidden = false;
|
||||
el.annotationDetail.hidden = true;
|
||||
el.annotationStatus.textContent = "";
|
||||
el.annotationClose.textContent = "取消";
|
||||
el.annotationNote.showModal();
|
||||
el.annotationRemark.focus();
|
||||
}
|
||||
|
||||
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") {
|
||||
state.annotationEdit = item;
|
||||
el.annotationRemark.value = item.remark || "";
|
||||
el.annotationCreator.value = item.creator || "";
|
||||
el.annotationFields.hidden = el.annotationSubmit.hidden = false;
|
||||
el.annotationDetail.hidden = true;
|
||||
el.annotationStatus.textContent = "";
|
||||
el.annotationClose.textContent = "取消";
|
||||
el.annotationNote.showModal();
|
||||
el.annotationRemark.focus();
|
||||
editAnnotationDetails(item);
|
||||
return;
|
||||
}
|
||||
if (canEditObject(item, "enter")) return enterCompositeScope(item.index, item.id);
|
||||
if (item.type === "TextObject") {
|
||||
if (!confirmTextReflow(item)) return;
|
||||
} else if (!canEditObject(item, item.type === "ImageObject" ? "replaceImage" : "update")) return;
|
||||
@@ -1440,13 +1600,7 @@ async function editCanvasObject(item) {
|
||||
|
||||
function mountEditorObjects(index, page, surface) {
|
||||
const scope = state.composite?.index === index ? state.composite : null;
|
||||
const annotations = state.renderAnnotations ? (page.annotations || []).filter(annotation => annotation.visible).map(annotation => ({
|
||||
...annotation, id: `annotation:${annotation.id}`, type: "Annotation", width: annotation.width || 6, height: annotation.height || 6,
|
||||
background: annotation.type === "Watermark" || annotation.x <= 0.1 && annotation.y <= 0.1
|
||||
&& annotation.x + annotation.width >= page.width - 0.1 && annotation.y + annotation.height >= page.height - 0.1,
|
||||
capabilities: { update: true, move: true, delete: true },
|
||||
})) : [];
|
||||
canvasEditor.mount(index, { ...page, objects: scope ? scope.objects : [...page.objects, ...annotations] }, surface);
|
||||
canvasEditor.mount(index, { ...page, objects: scope ? scope.objects : page.objects.filter(item => state.renderAnnotations || item.type !== "Annotation") }, surface);
|
||||
}
|
||||
|
||||
function changeAnnotations(item, action, ...args) {
|
||||
@@ -1904,7 +2058,7 @@ function updateDrawingControls() {
|
||||
const tool = canvasEditor.tool;
|
||||
const path = selectedPath(canvasEditor.selected);
|
||||
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) {
|
||||
el.shapeFill.checked = false;
|
||||
el.shapeStroke.checked = true;
|
||||
@@ -1913,6 +2067,9 @@ function updateDrawingControls() {
|
||||
}
|
||||
const disabled = !state.editing || !state.ready || state.exporting;
|
||||
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"]]) {
|
||||
button.disabled = disabled || !pageCan("insert");
|
||||
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];
|
||||
const members = item?.items || (item ? [item] : []);
|
||||
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 (scoped && !operation) throw new Error("内部对象暂不支持此操作");
|
||||
const doc = scoped
|
||||
? 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);
|
||||
if (openSeq !== state.openSeq) {
|
||||
return;
|
||||
@@ -1975,7 +2136,7 @@ async function changeDocument(name, item, ...args) {
|
||||
const restoredIndex = location ? doc.pages.findIndex(page => page.id === location.page) : -1;
|
||||
if (restoredIndex >= 0) {
|
||||
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();
|
||||
}
|
||||
if (restoring && JSON.stringify(doc.outlines) !== JSON.stringify(state.doc.outlines)) {
|
||||
@@ -2000,17 +2161,17 @@ async function changeDocument(name, item, ...args) {
|
||||
rememberEditorView(before, revision);
|
||||
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);
|
||||
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;
|
||||
canvasEditor.setTool("");
|
||||
if (name !== "ofdgoInsertInk") canvasEditor.setTool("");
|
||||
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 };
|
||||
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);
|
||||
} else {
|
||||
const samePage = doc.pages.findIndex((page) => page.id === previous.id);
|
||||
@@ -2029,7 +2190,9 @@ async function changeDocument(name, item, ...args) {
|
||||
}
|
||||
} catch (err) {
|
||||
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;
|
||||
} else if (el.batchPagesPanel.open) {
|
||||
el.batchPagesStatus.textContent = err.message;
|
||||
@@ -2588,6 +2751,8 @@ async function openOFD(file) {
|
||||
el.paragraphPanel.close();
|
||||
el.infoPanel.close();
|
||||
el.annotationNote.close();
|
||||
el.annotationCreate.close();
|
||||
el.objectPicker.close();
|
||||
el.importPanel.close();
|
||||
el.batchPagesPanel.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"
|
||||
|| !canEditObject(item, item.type === "TextObject" ? "reflow" : item.type === "ImageObject" ? "replaceImage" : "update"));
|
||||
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.multiSelectButton.disabled = !state.editing || !canvasEditor.enabled || !state.ready || state.exporting;
|
||||
const selection = selectedText(item);
|
||||
|
||||
+96
-16
@@ -178,7 +178,6 @@ function paintShape(node, shape, box) {
|
||||
export function canEditObject(item, capability) {
|
||||
if (!item) return false;
|
||||
const objects = item.items || [item];
|
||||
if (objects.some(object => object.type === "Annotation") && objects.some(object => object.type !== "Annotation")) return false;
|
||||
return objects.every(object => Boolean(object.capabilities?.[capability] || capability === "move" && object.capabilities?.transform));
|
||||
}
|
||||
|
||||
@@ -191,7 +190,6 @@ export function missingGlyphMessage(diagnostic, action = "请更换字体") {
|
||||
}
|
||||
|
||||
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: "暂不支持此样式",
|
||||
unsupportedContainer: "暂不支持此对象结构", invalidObject: "对象数据异常" };
|
||||
const reasons = (item?.items || (item ? [item] : [])).map(object => {
|
||||
@@ -231,6 +229,15 @@ export class CanvasEditor {
|
||||
viewer.addEventListener("pointermove", (event) => this.move(event));
|
||||
viewer.addEventListener("pointerup", (event) => this.end(event));
|
||||
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("keydown", (event) => this.keyDown(event));
|
||||
viewer.addEventListener("keyup", (event) => this.keyUp(event));
|
||||
@@ -282,12 +289,10 @@ export class CanvasEditor {
|
||||
const layer = document.createElement("div");
|
||||
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 objects = [...page.objects].sort((a, b) => Number(Boolean(b.background)) - Number(Boolean(a.background))
|
||||
|| (artwork.get(a.id)?.order ?? 0) - (artwork.get(b.id)?.order ?? 0));
|
||||
const objects = [...page.objects].sort((a, b) => (artwork.get(a.id)?.order ?? 0) - (artwork.get(b.id)?.order ?? 0));
|
||||
for (const object of objects) {
|
||||
const node = document.createElement("div");
|
||||
node.className = "edit-object";
|
||||
node.classList.toggle("edit-background", Boolean(object.background));
|
||||
node.classList.toggle("read-only", !canEditObject(object, "move"));
|
||||
node.title = objectEditReason(object);
|
||||
if (lineShape(object.shape)) {
|
||||
@@ -298,7 +303,7 @@ export class CanvasEditor {
|
||||
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 };
|
||||
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");
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.classList.add("edit-outline");
|
||||
@@ -519,6 +524,7 @@ export class CanvasEditor {
|
||||
}
|
||||
if (this.tool) {
|
||||
if (this.tool.startsWith("erase-")) this.startErase(event);
|
||||
else if (this.tool.startsWith("pen-")) this.startInk(event);
|
||||
else this.startShape(event);
|
||||
return;
|
||||
}
|
||||
@@ -533,9 +539,8 @@ export class CanvasEditor {
|
||||
node = target?.node;
|
||||
this.focus();
|
||||
const additive = event.shiftKey || event.ctrlKey || event.metaKey || this.multiple;
|
||||
if (!target || target.background && !this.items().includes(target)) {
|
||||
if (!target) {
|
||||
this.startMarquee(event, additive);
|
||||
if (this.drag?.marquee) this.drag.target = target;
|
||||
return;
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
pageItems(index) {
|
||||
return [...this.viewer.querySelectorAll(".edit-object")].map(node => this.nodes.get(node)).filter(item => item?.index === index);
|
||||
}
|
||||
|
||||
cycleSelection(event) {
|
||||
const items = this.itemsAt(event);
|
||||
if (!items.length) return;
|
||||
@@ -602,6 +611,10 @@ export class CanvasEditor {
|
||||
}
|
||||
event.preventDefault();
|
||||
drag.pointer = event;
|
||||
if (drag.ink) {
|
||||
this.moveInk(event, drag);
|
||||
return;
|
||||
}
|
||||
if (drag.erase) {
|
||||
this.moveErase(event, drag);
|
||||
return;
|
||||
@@ -729,6 +742,16 @@ export class CanvasEditor {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
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
|
||||
@@ -746,12 +769,19 @@ export class CanvasEditor {
|
||||
this.drag = null;
|
||||
drag.preview.remove();
|
||||
this.viewer.releasePointerCapture(drag.pointerID);
|
||||
if (drag.target && !drag.moved) this.setSelection([...drag.base, drag.target]);
|
||||
return;
|
||||
}
|
||||
if (drag.shape) {
|
||||
this.drag = null;
|
||||
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") {
|
||||
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));
|
||||
@@ -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) {
|
||||
const surface = event.target.closest(".page-surface") || this.nearestSurface(event);
|
||||
const page = this.pages.get(surface);
|
||||
if (!page) return;
|
||||
event.preventDefault();
|
||||
this.focus();
|
||||
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),
|
||||
this.drag = { erase: this.tool, page, surface, items: [...surface.querySelectorAll(".edit-object")].map(node => this.nodes.get(node)),
|
||||
erased: new Set(), shape: "rectangle", pointerID: event.pointerId, clientX: event.clientX, clientY: event.clientY,
|
||||
rect: surface.getBoundingClientRect(), rotation: this.options.rotation() };
|
||||
if (this.tool === "erase-region") Object.assign(this.drag, this.createPreview(surface, page, "rectangle",
|
||||
@@ -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) };
|
||||
paintShape(drag.node, "rectangle", box);
|
||||
if (Math.hypot(event.clientX-drag.clientX, event.clientY-drag.clientY) < 3) return;
|
||||
drag.moved = true;
|
||||
const items = [...drag.base];
|
||||
for (const node of drag.surface.querySelectorAll(".edit-object")) {
|
||||
const item = this.nodes.get(node);
|
||||
if (item.background && (item.x < box.x || item.y < box.y || item.x + item.width > box.x + box.width || item.y + item.height > box.y + box.height)) continue;
|
||||
if (!items.includes(item) && item.x <= box.x+box.width && item.x+item.width >= box.x && item.y <= box.y+box.height && item.y+item.height >= box.y) {
|
||||
if (!items.includes(item) && item.x >= box.x && item.x+item.width <= box.x+box.width && item.y >= box.y && item.y+item.height <= box.y+box.height) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
@@ -895,13 +965,14 @@ export class CanvasEditor {
|
||||
}
|
||||
event.preventDefault();
|
||||
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();
|
||||
if (lineShape(shape)) {
|
||||
style.fill = false;
|
||||
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(),
|
||||
clientX: event.clientX, clientY: event.clientY };
|
||||
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) {
|
||||
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 edit = event.key === "Enter" || event.key === "F2";
|
||||
if (event.key !== "Escape" && event.key !== "Delete" && !edit && !directions[event.key]) {
|
||||
|
||||
+659
-6
@@ -29,6 +29,7 @@ import (
|
||||
"io"
|
||||
"math"
|
||||
"path"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall/js"
|
||||
@@ -119,6 +120,10 @@ func RunWASM() {
|
||||
registerCallback("ofdgoBatchPages", batchPages)
|
||||
registerCallback("ofdgoChangeOutline", changeOutline)
|
||||
registerCallback("ofdgoChangeAnnotations", changeAnnotations)
|
||||
registerCallback("ofdgoChangeSelection", changeSelection)
|
||||
registerCallback("ofdgoWriteAnnotation", writeAnnotation)
|
||||
registerCallback("ofdgoReadAnnotation", readAnnotation)
|
||||
registerCallback("ofdgoInsertInk", insertInk)
|
||||
registerCallback("ofdgoStyleObjects", styleObjects)
|
||||
registerCallback("ofdgoMoveOutline", moveOutline)
|
||||
registerCallback("ofdgoCaptureStyle", captureStyle)
|
||||
@@ -635,9 +640,10 @@ var currentEditor *ofdgo.Editor
|
||||
|
||||
// editorClipboard 当前编辑文档中的对象快照与剪贴板标识
|
||||
type editorClipboard struct {
|
||||
token string
|
||||
objects []ofdgo.GraphicObject
|
||||
composite *ofdgo.CompositeSelection
|
||||
token string
|
||||
objects []ofdgo.GraphicObject
|
||||
composite *ofdgo.CompositeSelection
|
||||
annotations *ofdgo.AnnotationSelection
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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 统一顶层与内部对象的操作能力和受限原因
|
||||
// 入参: capability 库层能力, kind 对象类型
|
||||
// 返回: 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) {
|
||||
parts := strings.Split(value, "/")
|
||||
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:] {
|
||||
index, err := strconv.Atoi(part)
|
||||
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)
|
||||
} 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 {
|
||||
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()
|
||||
if key != "" {
|
||||
if copiedObjects.annotations != nil {
|
||||
return nil, fmt.Errorf("annotations cannot be nested in a page object")
|
||||
}
|
||||
path, err := compositePath(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1463,7 +2111,12 @@ func pasteObjects(args []js.Value) (any, error) {
|
||||
info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
||||
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 复制快照并更新预览,返回新对象标识
|
||||
@@ -2448,7 +3101,7 @@ func changeObjects(apply func() error) (any, error) {
|
||||
return nil, fmt.Errorf("no document is being edited")
|
||||
}
|
||||
revision := currentEditor.Revision()
|
||||
if err := apply(); err != nil {
|
||||
if err := currentEditor.Transaction(func(*ofdgo.Editor) error { return apply() }); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if currentEditor.Revision() == revision {
|
||||
|
||||
@@ -156,8 +156,13 @@ func (r *Reader) parseAnnotations(doc *Document) error {
|
||||
if r.Annots == nil {
|
||||
r.Annots = make(map[string][]Annotation)
|
||||
}
|
||||
r.annotationFiles = make(map[string][]string)
|
||||
for _, page := range annotations.Page {
|
||||
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)
|
||||
if err != nil {
|
||||
continue
|
||||
|
||||
+1
-22
@@ -18,7 +18,6 @@ import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"maps"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -284,25 +283,5 @@ func (e *Editor) editAnnotations(index int, ids []string, edit func([]byte, *edi
|
||||
if len(parts) == 0 {
|
||||
return nil
|
||||
}
|
||||
files := maps.Clone(reader.files)
|
||||
if files == nil {
|
||||
files = make(map[string][]byte)
|
||||
}
|
||||
maps.Copy(files, parts)
|
||||
updated := &Reader{Zip: reader.Zip, files: files}
|
||||
if err := updated.initRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
doc, err := updated.Doc()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before, after := e.source, *e.source
|
||||
after.reader, after.document = updated, doc
|
||||
e.source = &after
|
||||
if change := e.recordChange(); change != nil {
|
||||
change.undo = func(e *Editor) { e.source = before }
|
||||
change.redo = func(e *Editor) { e.source = &after }
|
||||
}
|
||||
return nil
|
||||
return e.commitAnnotationParts(e.source, parts)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -27,11 +27,13 @@ import (
|
||||
"github.com/tdewolff/canvas"
|
||||
)
|
||||
|
||||
// ObjectPath 定位页面顶层对象及复合对象内的成员,Children为从0开始的逐层绘制序号
|
||||
// ObjectPath 定位页面顶层对象或注解外观内的成员,Children为从0开始的逐层绘制序号
|
||||
// Annotation非空时定位该注解的Appearance,ID留空
|
||||
// 路径不依赖资源标识,几何变换、资源隔离及撤销重做不改变路径;成员增删或排序后需重新枚举
|
||||
type ObjectPath struct {
|
||||
ID string `json:"id"`
|
||||
Children []int `json:"children,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Annotation string `json:"annotation,omitempty"`
|
||||
Children []int `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// CompositeMember 复合对象的直接成员快照,Object包含有效样式,不用于整体替换原对象
|
||||
@@ -294,6 +296,9 @@ func compositeOrientation(box Box, matrix Matrix) Matrix {
|
||||
// 入参: page 页面索引, path 复合对象路径
|
||||
// 返回: *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)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
@@ -361,6 +366,9 @@ func newEditorCompositeNode(data []byte) (*editorCompositeNode, error) {
|
||||
var object GraphicObject
|
||||
object.Type = node.name.Local
|
||||
switch object.Type {
|
||||
case "Appearance":
|
||||
object.Type = "CompositeObject"
|
||||
err = xml.Unmarshal(data, &object.CompositeGraphicUnit)
|
||||
case "TextObject":
|
||||
err = xml.Unmarshal(data, &object.TextObject)
|
||||
case "PathObject":
|
||||
@@ -772,6 +780,9 @@ func (e *Editor) editCompositeScope(page int, path ObjectPath, edit func(*Render
|
||||
if err != nil || !changed {
|
||||
return err
|
||||
}
|
||||
if path.Annotation != "" {
|
||||
return e.writeAnnotationScope(page, path.Annotation, data, root.states)
|
||||
}
|
||||
next, err := newEditorCompositeNode(data)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -278,7 +278,12 @@ func (e *Editor) CaptureCompositeObjects(page int, path ObjectPath, indexes []in
|
||||
slices.Sort(indexes)
|
||||
var owner *editorCompositeNode
|
||||
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 {
|
||||
if i < 0 || i >= len(nodes) || j > 0 && indexes[j-1] == i {
|
||||
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 {
|
||||
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
|
||||
err := e.pasteCompositeSelection(selection, func(objects []GraphicObject) error {
|
||||
var err error
|
||||
|
||||
@@ -138,6 +138,12 @@ func (e *Editor) CopyObjectsToComposite(page int, path ObjectPath, objects []Gra
|
||||
} else {
|
||||
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 {
|
||||
content, err := editorXMLContainer("Content", nil, content)
|
||||
if err != nil {
|
||||
|
||||
@@ -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)}
|
||||
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)
|
||||
for i, page := range e.pages {
|
||||
if err := progress.report("fonts", i, total); err != nil {
|
||||
|
||||
+68
-1
@@ -14,7 +14,74 @@
|
||||
|
||||
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 页面或对象操作及其修订标识
|
||||
type editorChange struct {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -31,13 +31,15 @@ import (
|
||||
|
||||
// editorSource 保留输入包及按需加载的原始页面
|
||||
type editorSource struct {
|
||||
reader *Reader
|
||||
document *Document
|
||||
info DocInfo
|
||||
directory string
|
||||
pages map[string]*editorSourcePage
|
||||
origins map[string]*editorObjectOrigin
|
||||
idsReady bool
|
||||
reader *Reader
|
||||
document *Document
|
||||
info DocInfo
|
||||
directory string
|
||||
pages map[string]*editorSourcePage
|
||||
origins map[string]*editorObjectOrigin
|
||||
idsReady bool
|
||||
annotationStates map[string]map[string]editorCompositeState
|
||||
annotationPages map[string]bool
|
||||
}
|
||||
|
||||
// editorObjectOrigin 保留原对象的XML语义,供复制和局部更新复用
|
||||
|
||||
@@ -28,6 +28,9 @@ func (e *Editor) prunePageReferences(parts map[string][]byte) error {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,22 @@ import (
|
||||
"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 绕对象可见范围中心旋转
|
||||
// 入参: page 页面索引, id 对象标识, degrees 顺时针角度
|
||||
// 返回: error 错误信息
|
||||
|
||||
+101
@@ -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
|
||||
}
|
||||
@@ -45,6 +45,7 @@ type Reader struct {
|
||||
doc *Document
|
||||
Stamps map[string][]Stamp
|
||||
Annots map[string][]Annotation
|
||||
annotationFiles map[string][]string
|
||||
fileIndex map[string]*zip.File
|
||||
fileIndexFold map[string]*zip.File
|
||||
files map[string][]byte
|
||||
|
||||
@@ -37,6 +37,35 @@ type ObjectContour struct {
|
||||
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 获取路径、图片或复合对象的实际绘制区域,包含描边、虚线、变换及对象裁剪
|
||||
// 图片不解码像素,底纹不展开图案单元;不包含页面边界或父级变换
|
||||
// 入参: object 路径、图片或复合对象, drawParam 图层绘制参数标识
|
||||
|
||||
@@ -143,7 +143,7 @@ func (r *Renderer) renderSVGResources(page *PageContent, writer io.Writer, image
|
||||
return SVGResources{Fonts: s.fonts, Images: s.images}, buffer.Flush()
|
||||
}
|
||||
|
||||
// beginObject 标记直接对象及复合成员,模板与注解内部图元不单独标记
|
||||
// beginObject 标记直接对象及复合或注解成员,模板图元不单独标记
|
||||
// 入参: object 图形对象
|
||||
// 返回: bool 是否写入分组
|
||||
func (s *svgResourceRenderer) beginObject(object *GraphicObject) bool {
|
||||
@@ -176,7 +176,7 @@ func (s *svgResourceRenderer) beginAnnotation(id string) bool {
|
||||
if s.objects == nil {
|
||||
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))
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ func (e *Editor) WritePagesTo(writer io.Writer, indexes []int) (int64, error) {
|
||||
if e.source != nil {
|
||||
source := *e.source
|
||||
source.pages = make(map[string]*editorSourcePage, len(indexes))
|
||||
source.annotationPages = nil
|
||||
for _, index := range indexes {
|
||||
id := e.pages[index].ID
|
||||
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)
|
||||
promoted := 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 {
|
||||
var original map[string]GraphicObject
|
||||
if e.source != nil {
|
||||
|
||||
Reference in new issue
Block a user