feat(编辑能力): 完善页面、目录与对象样式编辑,优化轮廓选中和撤销体验
build.yaml / build (push) Successful in 36s

This commit is contained in:
xiaoqidun committed 2026-09-17 00:06:12 +08:00
1 parent ee48ca666d
commit 4a6174c2ae
12 files changed
+1249 -101

No files matched your search

+32 -2
View File
@@ -89,7 +89,7 @@
<option value="horizontal">水平</option>
<option value="vertical">垂直</option>
</select>
<span>对象</span>
<button id="objectStyleButton" class="editor-group-action" type="button" title="对象样式" aria-label="对象样式" aria-haspopup="dialog" aria-controls="objectStylePanel" disabled>对象</button>
</div>
<div class="editor-group editor-erase">
<button id="eraseObjectButton" class="button" type="button" title="整对象擦除" aria-label="整对象擦除" aria-pressed="false">对象</button>
@@ -161,9 +161,39 @@
<button id="movePagePrevButton" class="button" type="button" aria-label="页面前移">前移</button>
<button id="movePageNextButton" class="button" type="button" aria-label="页面后移">后移</button>
<button id="pageSettingsButton" class="button" type="button" aria-label="页面设置" aria-haspopup="dialog" aria-controls="pagePanel">设置</button>
<span>页面</span>
<button id="batchPagesButton" class="editor-group-action" type="button" title="批量页面" aria-label="批量页面" aria-haspopup="dialog" aria-controls="batchPagesPanel">页面</button>
</div>
</div>
<dialog id="batchPagesPanel" class="form-dialog" aria-label="批量页面">
<form id="batchPagesForm">
<div class="form-row"><label for="batchPageAction">操作</label><select id="batchPageAction"><option value="copy">复制</option><option value="delete">删除</option></select></div>
<div class="form-row"><label for="batchPageRange">页码</label><input id="batchPageRange" type="text" required autocomplete="off" spellcheck="false" autofocus></div>
<div class="form-actions"><div id="batchPagesStatus" class="form-status" role="status"></div><button id="batchPagesCancel" class="button" type="button">取消</button><button id="batchPagesSubmit" class="button" type="submit">复制</button></div>
</form>
</dialog>
<dialog id="objectStylePanel" class="form-dialog" aria-label="对象样式">
<form id="objectStyleForm">
<div class="form-row"><label for="objectOpacity">透明</label><div class="form-unit"><input id="objectOpacity" type="number" min="0" max="100" step="any" aria-label="透明度" autofocus><span>%</span></div></div>
<div id="objectStrokeFields">
<div class="form-row"><label for="objectDash">线型</label><select id="objectDash"><option value="mixed" hidden>混合</option><option value="solid">实线</option><option value="dash">虚线</option><option value="dot">点线</option><option value="dashdot">点划</option><option value="custom">自定</option></select></div>
<div id="objectDashRow" class="form-row" hidden><label for="objectDashPattern">间隔</label><div class="form-unit"><input id="objectDashPattern" type="text" autocomplete="off" spellcheck="false"><span>mm</span></div></div>
<div class="form-row"><label for="objectDashOffset">偏移</label><div class="form-unit"><input id="objectDashOffset" type="number" step="any"><span>mm</span></div></div>
<div class="form-pair">
<div class="form-row"><label for="objectCap">端点</label><select id="objectCap"><option value="mixed" hidden>混合</option><option value="Butt">平头</option><option value="Round">圆头</option><option value="Square">方头</option></select></div>
<div class="form-row"><label for="objectJoin">转角</label><select id="objectJoin"><option value="mixed" hidden>混合</option><option value="Miter">尖角</option><option value="Round">圆角</option><option value="Bevel">斜角</option></select></div>
</div>
</div>
<div class="form-actions"><div id="objectStyleStatus" class="form-status" role="status"></div><button id="objectStyleCancel" class="button" type="button">取消</button><button class="button" type="submit">确定</button></div>
</form>
</dialog>
<dialog id="outlinePanel" class="form-dialog" aria-label="编辑目录">
<form id="outlineForm">
<div class="form-row"><label for="outlineTitle">标题</label><input id="outlineTitle" type="text" required autofocus></div>
<div class="form-row"><label for="outlineParent">上级</label><select id="outlineParent"></select></div>
<div class="form-row"><label for="outlinePage">页码</label><input id="outlinePage" type="number" min="1" step="1" placeholder="无" aria-label="目标页码,留空表示无目标"></div>
<div class="form-actions"><div id="outlineStatus" class="form-status" role="status"></div><button id="outlineCancel" class="button" type="button">取消</button><button id="outlineSubmit" class="button" type="submit">新增</button></div>
</form>
</dialog>
<dialog id="createPanel" class="form-dialog" aria-label="新建文档">
<form id="createForm">
<div class="form-row">
+89
View File
@@ -1470,6 +1470,95 @@ button.outline-link:hover {
cursor: default;
}
.edit-object.edit-contour,
.edit-object.edit-contour.selected,
.edit-object.edit-contour.erasing {
outline: none;
background: transparent;
}
.edit-outline {
display: none;
position: absolute;
width: 100%;
height: 100%;
min-width: 1px;
min-height: 1px;
overflow: visible;
pointer-events: none;
fill: none;
stroke: var(--accent);
stroke-width: calc(1px / var(--surface-scale));
}
.edit-outline > * {
vector-effect: non-scaling-stroke;
}
.edit-object:hover > .edit-outline,
.edit-object.selected > .edit-outline,
.edit-object.erasing > .edit-outline {
display: block;
}
.edit-object.erasing > .edit-outline {
stroke-dasharray: 3 2;
}
.outline-tools {
display: flex;
gap: 6px;
margin-bottom: 10px;
}
.outline-link.selected {
background: var(--accent-soft);
color: var(--accent);
}
.page-list-item {
position: relative;
}
.thumb-grip {
position: absolute;
right: 8px;
bottom: 8px;
width: 24px;
height: 28px;
cursor: grab;
touch-action: none;
color: var(--text-muted);
}
.thumb-grip::before {
content: "";
position: absolute;
left: 7px;
top: 7px;
width: 3px;
height: 3px;
background: currentColor;
box-shadow: 6px 0 currentColor, 0 6px currentColor, 6px 6px currentColor, 0 12px currentColor, 6px 12px currentColor;
}
.page-list-item.reordering {
opacity: 0.5;
}
.page-list-item.drop-before::before,
.page-list-item.drop-after::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 2px;
background: var(--accent);
}
.page-list-item.drop-before::before { top: -6px; }
.page-list-item.drop-after::after { bottom: -6px; }
.edit-line::before {
position: absolute;
inset: calc(-4px / var(--surface-scale));
+277 -13
View File
@@ -39,6 +39,9 @@ const state = {
savedRevision: null,
insertObject: null,
importPageCount: 0,
outlineSelection: null,
outlineAction: "add",
styleOriginal: null,
objectClipboard: null,
textFonts: [],
textFontID: null,
@@ -164,6 +167,35 @@ const el = {
movePagePrevButton: document.querySelector("#movePagePrevButton"),
movePageNextButton: document.querySelector("#movePageNextButton"),
pageSettingsButton: document.querySelector("#pageSettingsButton"),
batchPagesButton: document.querySelector("#batchPagesButton"),
batchPagesPanel: document.querySelector("#batchPagesPanel"),
batchPagesForm: document.querySelector("#batchPagesForm"),
batchPageAction: document.querySelector("#batchPageAction"),
batchPageRange: document.querySelector("#batchPageRange"),
batchPagesStatus: document.querySelector("#batchPagesStatus"),
batchPagesCancel: document.querySelector("#batchPagesCancel"),
batchPagesSubmit: document.querySelector("#batchPagesSubmit"),
objectStyleButton: document.querySelector("#objectStyleButton"),
objectStylePanel: document.querySelector("#objectStylePanel"),
objectStyleForm: document.querySelector("#objectStyleForm"),
objectOpacity: document.querySelector("#objectOpacity"),
objectStrokeFields: document.querySelector("#objectStrokeFields"),
objectDash: document.querySelector("#objectDash"),
objectDashRow: document.querySelector("#objectDashRow"),
objectDashPattern: document.querySelector("#objectDashPattern"),
objectDashOffset: document.querySelector("#objectDashOffset"),
objectCap: document.querySelector("#objectCap"),
objectJoin: document.querySelector("#objectJoin"),
objectStyleStatus: document.querySelector("#objectStyleStatus"),
objectStyleCancel: document.querySelector("#objectStyleCancel"),
outlinePanel: document.querySelector("#outlinePanel"),
outlineForm: document.querySelector("#outlineForm"),
outlineTitle: document.querySelector("#outlineTitle"),
outlineParent: document.querySelector("#outlineParent"),
outlinePage: document.querySelector("#outlinePage"),
outlineStatus: document.querySelector("#outlineStatus"),
outlineCancel: document.querySelector("#outlineCancel"),
outlineSubmit: document.querySelector("#outlineSubmit"),
pagePanel: document.querySelector("#pagePanel"),
pageForm: document.querySelector("#pageForm"),
pagePortrait: document.querySelector("#pagePortrait"),
@@ -549,6 +581,41 @@ editorClick(el.deletePageButton, () => changeDocument("ofdgoChangePage", null, "
editorClick(el.movePagePrevButton, () => changeDocument("ofdgoChangePage", null, "move", state.pageIndex, state.pageIndex - 1));
editorClick(el.movePageNextButton, () => changeDocument("ofdgoChangePage", null, "move", state.pageIndex, state.pageIndex + 1));
editorClick(el.pageSettingsButton, openPagePanel);
editorClick(el.batchPagesButton, () => {
el.batchPageAction.value = "copy";
el.batchPagesSubmit.textContent = "复制";
el.batchPageRange.value = String(state.pageIndex + 1);
el.batchPagesStatus.textContent = "";
el.batchPagesPanel.showModal();
el.batchPageRange.select();
});
el.batchPagesCancel.addEventListener("click", () => el.batchPagesPanel.close());
el.batchPageAction.addEventListener("change", () => {
el.batchPagesSubmit.textContent = el.batchPageAction.value === "copy" ? "复制" : "删除";
});
el.batchPagesForm.addEventListener("submit", async event => {
event.preventDefault();
if (await changeDocument("ofdgoBatchPages", null, el.batchPageAction.value, el.batchPageRange.value)) el.batchPagesPanel.close();
});
editorClick(el.objectStyleButton, openObjectStyle);
el.objectStyleCancel.addEventListener("click", () => el.objectStylePanel.close());
el.objectDash.addEventListener("change", () => {
el.objectDashRow.hidden = el.objectDash.value !== "custom";
el.objectDashPattern.required = !el.objectDashRow.hidden;
});
el.objectStyleForm.addEventListener("submit", async event => {
event.preventDefault();
const style = readObjectStyle();
const item = canvasEditor.selected;
if (await changeDocument("ofdgoStyleObjects", { ...item, id: canvasEditor.items().map(member => member.id) }, style)) el.objectStylePanel.close();
});
el.outlineCancel.addEventListener("click", () => el.outlinePanel.close());
el.outlineForm.addEventListener("submit", async event => {
event.preventDefault();
const path = state.outlineAction === "add" ? JSON.parse(el.outlineParent.value) : state.outlineSelection;
const page = el.outlinePage.value === "" ? -1 : Number(el.outlinePage.value) - 1;
if (await changeDocument("ofdgoChangeOutline", null, state.outlineAction, path, el.outlineTitle.value, page)) el.outlinePanel.close();
});
el.pageCancel.addEventListener("click", () => el.pagePanel.close());
el.pageWidth.addEventListener("input", updatePageDirection);
el.pageHeight.addEventListener("input", updatePageDirection);
@@ -797,7 +864,73 @@ 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;
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;
}
function openObjectStyle() {
const items = canvasEditor.items();
if (!items.length) return;
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", "");
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");
el.objectDash.value = dash === null ? "mixed" : dash === "" ? "solid" : "custom";
el.objectDashPattern.value = dash || "";
el.objectDashRow.hidden = el.objectDash.value !== "custom";
el.objectDashPattern.required = !el.objectStrokeFields.hidden && !el.objectDashRow.hidden;
el.objectDashOffset.value = shared("dashOffset", 0) ?? "";
el.objectCap.value = shared("cap", "") || (shared("cap", "") === null ? "mixed" : "Butt");
el.objectJoin.value = shared("join", "") || (shared("join", "") === null ? "mixed" : "Miter");
state.styleOriginal = Object.fromEntries(["objectOpacity", "objectDash", "objectDashPattern", "objectDashOffset", "objectCap", "objectJoin"].map(key => [key, el[key].value]));
el.objectStyleStatus.textContent = "";
el.objectStylePanel.showModal();
}
function readObjectStyle() {
const style = {}, changed = key => el[key].value !== state.styleOriginal[key];
if (changed("objectOpacity") && el.objectOpacity.value !== "") style.alpha = Math.round((1 - Number(el.objectOpacity.value) / 100) * 255);
if (!el.objectStrokeFields.hidden) {
if (changed("objectDash") || changed("objectDashPattern")) {
const patterns = { solid: "", dash: "3 2", dot: "0.3 1.5", dashdot: "3 1.5 0.3 1.5", custom: el.objectDashPattern.value.trim() };
if (el.objectDash.value in patterns) style.dashPattern = patterns[el.objectDash.value];
}
if (changed("objectDashOffset") && el.objectDashOffset.value !== "") style.dashOffset = Number(el.objectDashOffset.value);
if (changed("objectCap") && el.objectCap.value !== "mixed") style.cap = el.objectCap.value;
if (changed("objectJoin") && el.objectJoin.value !== "mixed") style.join = el.objectJoin.value;
}
return style;
}
function openOutlinePanel(action) {
state.outlineAction = action;
let outline = null, items = state.doc.outlines || [];
for (const index of state.outlineSelection || []) { outline = items[index]; items = outline.children || []; }
el.outlineTitle.value = action === "add" ? "" : outline.title;
el.outlinePage.value = action === "add" ? String(state.pageIndex + 1) : outline.page || "";
el.outlinePage.max = String(state.doc.pageCount);
el.outlineParent.replaceChildren();
const addOption = (path, title) => {
const option = document.createElement("option");
option.value = JSON.stringify(path);
option.textContent = title;
option.title = title;
el.outlineParent.append(option);
};
addOption([], "无");
const walk = (items, path = []) => items.forEach((item, index) => {
const next = [...path, index];
addOption(next, `${" ".repeat(path.length)}${item.title}`);
walk(item.children || [], next);
});
walk(state.doc.outlines || []);
el.outlineParent.value = JSON.stringify(action === "add" ? state.outlineSelection || [] : state.outlineSelection.slice(0, -1));
el.outlineParent.disabled = action !== "add";
el.outlineTitle.disabled = el.outlinePage.disabled = action === "delete";
el.outlineSubmit.textContent = { add: "新增", update: "确定", delete: "删除" }[action];
el.outlineStatus.textContent = action === "delete" && outline.children?.length ? "同时删除子目录" : "";
el.outlinePanel.showModal();
}
function openImportPanel() {
@@ -899,6 +1032,8 @@ async function toggleEditor() {
setPan(false);
}
updateControls();
renderOutlines(false);
renderPageList();
applyFit(false);
return;
}
@@ -1432,6 +1567,13 @@ async function changeDocument(name, item, ...args) {
updateControls();
return true;
}
if (name === "ofdgoChangeOutline") {
state.doc.outlines = doc.outlines;
renderOutlines(false);
showNavigation(el.outlinesTab);
updateControls();
return true;
}
const clearSelection = !item || ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoEraseObjects", "ofdgoEraseObjectsPath"].includes(name);
if (name === "ofdgoCopyObjects" || name === "ofdgoPasteObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") {
state.selectObjects = true;
@@ -1457,7 +1599,13 @@ async function changeDocument(name, item, ...args) {
}
} catch (err) {
if (openSeq === state.openSeq) {
if (el.infoPanel.open) {
if (el.batchPagesPanel.open) {
el.batchPagesStatus.textContent = err.message;
} else if (el.objectStylePanel.open) {
el.objectStyleStatus.textContent = err.message;
} else if (el.outlinePanel.open) {
el.outlineStatus.textContent = err.message;
} else if (el.infoPanel.open) {
el.infoStatus.textContent = err.message;
} else if (el.pagePanel.open) {
el.pageStatus.textContent = err.message;
@@ -1984,6 +2132,9 @@ async function openOFD(file) {
el.paragraphPanel.close();
el.infoPanel.close();
el.importPanel.close();
el.batchPagesPanel.close();
el.objectStylePanel.close();
el.outlinePanel.close();
updateControls();
await openDocument({ pageIndex: 0, resetScroll: true, openSeq });
} catch (err) {
@@ -2439,7 +2590,7 @@ async function openDocument(options = {}) {
resetPageFlow(options.keepPreview);
renderPageList();
if (options.keepPreview || options.resetScroll || el.outlineList.childElementCount === 0) {
renderOutlines();
renderOutlines(!options.keepPreview);
}
renderMeta();
renderPageFlow();
@@ -3514,18 +3665,36 @@ function showNavigation(selected) {
el.pageListPanel.scrollTop = state.navigationScroll.get(selected) || 0;
}
function renderOutlines() {
state.navigationScroll.clear();
el.pageListPanel.scrollTop = 0;
function renderOutlines(reset = true) {
const selected = !reset && [el.pagesTab, el.outlinesTab, el.searchTab].find(tab => tab.getAttribute("aria-selected") === "true");
const scrollTop = reset ? 0 : el.pageListPanel.scrollTop;
if (reset) state.navigationScroll.clear();
const outlines = state.doc.outlines || [];
el.pageListTitle.hidden = true;
el.navigationTabs.hidden = false;
el.outlinesTab.hidden = outlines.length === 0;
el.outlinesTab.hidden = outlines.length === 0 && !state.editing;
el.outlineList.replaceChildren();
state.outlineSelection = null;
if (state.editing) {
const tools = document.createElement("div");
tools.className = "outline-tools";
for (const [action, label] of [["add", "新增"], ["update", "修改"], ["delete", "删除"]]) {
const button = document.createElement("button");
button.className = "button";
button.type = "button";
button.textContent = label;
button.dataset.outlineAction = action;
button.disabled = action !== "add";
editorClick(button, () => openOutlinePanel(action));
tools.append(button);
}
el.outlineList.append(tools);
}
if (outlines.length > 0) {
el.outlineList.append(createOutlineList(outlines));
}
showNavigation(el.pagesTab);
el.pageListPanel.scrollTop = scrollTop;
showNavigation(selected && !selected.hidden ? selected : el.pagesTab);
}
function resetSearch(clearInput = true) {
@@ -3703,17 +3872,18 @@ function renderSearchHighlights(index) {
}
}
function createOutlineList(outlines) {
function createOutlineList(outlines, path = []) {
const list = document.createElement("ul");
list.className = "outline-list";
for (const outline of outlines) {
for (const [index, outline] of outlines.entries()) {
const current = [...path, index];
const item = document.createElement("li");
const hasChildren = outline.children?.length > 0;
const row = document.createElement(hasChildren ? "summary" : "div");
if (!hasChildren) {
row.className = "outline-leaf";
}
const link = document.createElement(outline.page ? "button" : "span");
const link = document.createElement(outline.page || state.editing ? "button" : "span");
link.className = "outline-link";
const title = document.createElement("span");
title.textContent = outline.title;
@@ -3725,16 +3895,24 @@ function createOutlineList(outlines) {
page.textContent = String(outline.page);
page.setAttribute("aria-label", `第 ${outline.page} 页`);
link.append(page);
}
if (outline.page || state.editing) {
link.type = "button";
link.addEventListener("click", (event) => {
event.preventDefault();
renderPage(outline.page - 1);
if (state.editing) {
state.outlineSelection = current;
for (const node of el.outlineList.querySelectorAll(".outline-link")) node.classList.toggle("selected", node === link);
for (const button of el.outlineList.querySelectorAll("[data-outline-action]")) button.disabled = false;
}
if (outline.page) renderPage(outline.page - 1);
});
}
row.append(link);
if (hasChildren) {
const details = document.createElement("details");
details.open = outline.expanded;
details.append(row, createOutlineList(outline.children));
details.append(row, createOutlineList(outline.children, current));
item.append(details);
} else {
item.append(row);
@@ -3778,6 +3956,7 @@ function renderPageList() {
size.textContent = `${formatSize(page.width)} x ${formatSize(page.height)} mm`;
button.append(thumb, label, size);
if (state.editing && pageCan("move", page.index)) enablePageDrag(button, page.index);
button.addEventListener("click", () => renderPage(page.index));
fragment.append(button);
thumbnailTargets.push([button, page.index]);
@@ -3797,6 +3976,83 @@ function resetThumbnails() {
}
}
function pageDropIndex(from, target, after) {
const position = target + Number(after);
return position > from ? position - 1 : position;
}
function enablePageDrag(button, index) {
const grip = document.createElement("span");
grip.className = "thumb-grip";
grip.title = "拖动排序";
grip.setAttribute("aria-hidden", "true");
button.append(grip);
grip.addEventListener("click", event => event.stopPropagation());
grip.addEventListener("pointerdown", event => {
if (event.button !== 0 || document.body.hasAttribute("aria-busy")) return;
event.preventDefault();
event.stopPropagation();
const seq = state.openSeq, pointer = event.pointerId;
let x = event.clientX, y = event.clientY, target = index, marker = null, frame = 0, dragging = false;
const clearMarker = () => { marker?.classList.remove("drop-before", "drop-after"); marker = null; };
const locate = () => {
clearMarker();
target = index;
const node = document.elementFromPoint(x, y)?.closest(".page-list-item");
if (!node || !el.pageList.contains(node)) return;
const rect = node.getBoundingClientRect(), after = y > rect.top + rect.height / 2;
target = pageDropIndex(index, Number(node.dataset.pageIndex), after);
if (target !== index) { marker = node; marker.classList.add(after ? "drop-after" : "drop-before"); }
};
const tick = () => {
if (seq !== state.openSeq || !state.editing) { finish(false); return; }
if (dragging) {
const rect = el.pageListPanel.getBoundingClientRect();
const delta = y < rect.top + 48 ? -10 : y > rect.bottom - 48 ? 10 : 0;
if (delta) el.pageListPanel.scrollTop += delta;
locate();
}
frame = requestAnimationFrame(tick);
};
const move = next => {
if (next.pointerId !== pointer) return;
x = next.clientX; y = next.clientY;
dragging ||= Math.hypot(x - event.clientX, y - event.clientY) > 4;
button.classList.toggle("reordering", dragging);
if (dragging) locate();
};
const finish = async commit => {
cancelAnimationFrame(frame);
grip.removeEventListener("pointermove", move);
grip.removeEventListener("pointerup", up);
grip.removeEventListener("pointercancel", cancel);
grip.removeEventListener("lostpointercapture", cancel);
clearMarker();
button.classList.remove("reordering");
if (grip.hasPointerCapture(pointer)) grip.releasePointerCapture(pointer);
if (commit && dragging && target !== index && seq === state.openSeq && await canvasEditor.commitText() && await canvasEditor.commitCrop()) {
await changeDocument("ofdgoChangePage", null, "move", index, target);
}
};
const up = next => { if (next.pointerId === pointer) finish(true); };
const cancel = () => finish(false);
grip.setPointerCapture(pointer);
grip.addEventListener("pointermove", move);
grip.addEventListener("pointerup", up);
grip.addEventListener("pointercancel", cancel);
grip.addEventListener("lostpointercapture", cancel);
frame = requestAnimationFrame(tick);
});
button.addEventListener("keydown", async event => {
if (!event.altKey || !["ArrowUp", "ArrowDown"].includes(event.key) || document.body.hasAttribute("aria-busy")) return;
event.preventDefault();
const target = index + (event.key === "ArrowUp" ? -1 : 1);
if (target >= 0 && target < state.doc.pageCount && await canvasEditor.commitText() && await canvasEditor.commitCrop()) {
if (await changeDocument("ofdgoChangePage", null, "move", index, target)) el.pageList.querySelector(`[data-page-index="${target}"]`)?.focus();
}
});
}
function layoutThumbnail(button, page) {
if (page.width <= 0 || page.height <= 0) {
return;
@@ -4665,6 +4921,7 @@ function updateEditorTools() {
const pagesDisabled = !state.editing || !state.ready || state.exporting;
el.insertTextButton.disabled = el.insertImageButton.disabled = pagesDisabled || !pageCan("insert");
el.saveButton.disabled = el.addPageButton.disabled = pagesDisabled;
el.batchPagesButton.disabled = pagesDisabled;
el.copyPageButton.disabled = pagesDisabled || !pageCan("copy");
el.pageSettingsButton.disabled = pagesDisabled || !pageCan("resize");
el.deletePageButton.disabled = pagesDisabled || !pageCan("delete") || state.doc?.pageCount <= 1;
@@ -4681,6 +4938,7 @@ function updateObjectControls(item, reset = false) {
const disabled = !item || Boolean(item.draft) || !state.ready || state.exporting;
el.deleteObjectButton.disabled = disabled || !canEditObject(item, "delete");
el.copyObjectButton.disabled = disabled || !canEditObject(item, "copy");
el.objectStyleButton.disabled = disabled || !canEditObject(item, "transform");
const cropping = Boolean(canvasEditor.crop);
el.objectAlign.disabled = disabled || cropping || !canEditObject(item, "arrange");
el.objectRotate.disabled = el.objectFlip.disabled = el.objectAlign.disabled;
@@ -4803,6 +5061,12 @@ function setBusy(busy, text = "", percent = 0, status = "") {
el.paragraphForm.inert = busy;
el.infoForm.inert = busy;
el.importForm.inert = busy;
el.batchPagesForm.inert = busy;
el.objectStyleForm.inert = busy;
el.outlineForm.inert = busy;
el.outlineList.inert = busy;
el.navigationTabs.inert = busy;
el.pageList.inert = busy;
el.editorTools.inert = busy;
el.fontList.inert = busy;
if (!busy) {
+23
View File
@@ -249,6 +249,18 @@ export class CanvasEditor {
node.setAttribute("role", "button");
node.setAttribute("aria-label", { ImageObject: "图片对象", TextObject: "文字对象", PathObject: "图形对象" }[object.type]);
const item = { ...object, index, page: { width: page.width, height: page.height }, node, surface };
if (object.type === "PathObject" && (object.shape || object.outline)) {
node.classList.add("edit-contour");
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.classList.add("edit-outline");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("preserveAspectRatio", "none");
const contour = document.createElementNS("http://www.w3.org/2000/svg", object.shape === "line" ? "line" : object.shape === "ellipse" ? "ellipse" : object.shape === "rectangle" ? "rect" : "path");
if (!object.shape) contour.setAttribute("d", object.outline);
svg.append(contour);
node.append(svg);
item.contour = { svg, path: contour };
}
this.nodes.set(node, item);
node.addEventListener("focus", () => this.select(item));
const handles = !canEditObject(object, "transform") ? [] : object.shape === "line" ? ["start", "end"]
@@ -297,6 +309,7 @@ export class CanvasEditor {
left: `${(item.x + change.x) * PX_PER_MM}px`, top: `${(item.y + change.y) * PX_PER_MM}px`,
width: `${item.width * change.scale * PX_PER_MM}px`, height: `${item.height * change.scale * PX_PER_MM}px`,
});
if (item.contour) item.contour.svg.setAttribute("viewBox", `${item.x} ${item.y} ${item.width} ${item.height}`);
if (item.textFrame) {
const { matrix: [a, b, c, d, e, f], width, height } = item.textFrame;
for (const handle of item.node.children) {
@@ -317,11 +330,21 @@ export class CanvasEditor {
});
if (item.shape === "line") {
for (const handle of item.node.children) {
if (!handle.dataset.corner) continue;
const end = handle.dataset.corner === "end";
handle.style.left = `${(box.x + (end ? box.width : 0) - x) * PX_PER_MM}px`;
handle.style.top = `${(box.y + (end ? box.height : 0) - y) * PX_PER_MM}px`;
}
}
if (item.contour) {
const { svg, path } = item.contour;
const width = Math.abs(box.width), height = Math.abs(box.height);
svg.setAttribute("viewBox", `0 0 ${width || 1} ${height || 1}`);
const values = item.shape === "line" ? { x1: box.x - x, y1: box.y - y, x2: box.x + box.width - x, y2: box.y + box.height - y }
: item.shape === "ellipse" ? { cx: width / 2, cy: height / 2, rx: width / 2, ry: height / 2 }
: { x: 0, y: 0, width, height };
for (const [key, value] of Object.entries(values)) path.setAttribute(key, String(value));
}
}
items() {
+127 -12
View File
@@ -110,6 +110,9 @@ func RunWASM() {
registerCallback("ofdgoEditDocument", editDocument)
registerCallback("ofdgoUpdateInfo", updateInfo)
registerCallback("ofdgoChangePage", changePage)
registerCallback("ofdgoBatchPages", batchPages)
registerCallback("ofdgoChangeOutline", changeOutline)
registerCallback("ofdgoStyleObjects", styleObjects)
registerCallback("ofdgoLoadImport", loadImport)
registerCallback("ofdgoImportPages", importPages)
registerCallback("ofdgoInsertText", insertText)
@@ -722,6 +725,40 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
if missing := capability.MissingGlyphs; missing != nil {
item["capabilities"].(map[string]any)["missingGlyphs"] = map[string]any{"fontID": missing.FontID, "characters": missing.Characters}
}
var alpha *int
switch object.Type {
case "TextObject":
alpha = object.TextObject.Alpha
case "ImageObject":
alpha = object.ImageObject.Alpha
case "PathObject":
alpha = object.PathObject.Alpha
}
item["alpha"] = 255
if alpha != nil {
item["alpha"] = *alpha
}
if object.Type == "PathObject" {
path := object.PathObject
item["dashPattern"], item["cap"], item["join"] = path.DashPattern, path.Cap, path.Join
item["dashOffset"] = 0.0
if path.DashOffset != nil {
item["dashOffset"] = *path.DashOffset
}
kind, geometry := path.Shape()
if kind != "" && capability.Update {
item["shape"] = string(kind)
item["geometry"] = map[string]any{"x": geometry.X, "y": geometry.Y, "width": geometry.W, "height": geometry.H}
} else if outline, err := path.Outline(); err == nil {
item["outline"] = outline
}
if capability.Update {
item["fill"], item["stroke"] = path.Fill != nil && *path.Fill, path.Stroke == nil || *path.Stroke
item["fillColor"] = editorColorHex(path.FillColor)
item["strokeColor"] = editorColorHex((*ofdgo.FillColor)(path.StrokeColor))
item["lineWidth"] = path.LineWidth * editorPathScale(path)
}
}
if object.Type == "ImageObject" && capability.Update {
full, err := object.ImageObject.ImageBounds()
if err != nil {
@@ -729,18 +766,6 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
}
item["imageBounds"] = editorBox(full)
}
if object.Type == "PathObject" && capability.Update {
path := object.PathObject
kind, geometry := path.Shape()
if kind != "" {
item["shape"] = string(kind)
item["geometry"] = map[string]any{"x": geometry.X, "y": geometry.Y, "width": geometry.W, "height": geometry.H}
}
item["fill"], item["stroke"] = path.Fill != nil && *path.Fill, path.Stroke == nil || *path.Stroke
item["fillColor"] = editorColorHex(path.FillColor)
item["strokeColor"] = editorColorHex((*ofdgo.FillColor)(path.StrokeColor))
item["lineWidth"] = path.LineWidth * editorPathScale(path)
}
if object.Type == "TextObject" {
value, layout := object.TextObject.TextLayout()
item["text"], item["font"], item["fontName"], item["size"] = value, object.TextObject.Font, fontNames[object.TextObject.Font], object.TextObject.Size
@@ -1010,6 +1035,96 @@ func changePage(args []js.Value) (any, error) {
return editorPageInfo{info, index}, nil
}
// batchPages 按页码范围批量复制或删除页面
// 入参: args 操作及页码表达式
// 返回: any 文档信息及目标页, error 错误信息
func batchPages(args []js.Value) (any, error) {
index := 0
info, err := changeObjects(func() error {
indexes, err := ofdgo.ParsePageRange(args[1].String(), currentEditor.PageCount())
if err != nil {
return err
}
switch args[0].String() {
case "copy":
copies, err := currentEditor.CopyPages(indexes)
if err == nil && len(copies) > 0 {
index = copies[0]
}
return err
case "delete":
if len(indexes) == currentEditor.PageCount() {
return fmt.Errorf("the document must contain at least one page")
}
if err := currentEditor.DeletePages(indexes); err != nil {
return err
}
index = min(indexes[0], currentEditor.PageCount()-1)
return nil
default:
return fmt.Errorf("unsupported page action %q", args[0].String())
}
})
if err != nil {
return nil, err
}
return editorPageInfo{info.(editorInfo), index}, nil
}
// changeOutline 新增、修改或删除层级目录
// 入参: args 操作、层级索引、标题及目标页面索引
// 返回: any 文档信息, error 错误信息
func changeOutline(args []js.Value) (any, error) {
return changeObjects(func() error {
path := make([]int, args[1].Length())
for i := range path {
path[i] = args[1].Index(i).Int()
}
switch args[0].String() {
case "add":
_, err := currentEditor.AddOutline(path, args[2].String(), args[3].Int())
return err
case "update":
return currentEditor.UpdateOutline(path, args[2].String(), args[3].Int())
case "delete":
return currentEditor.DeleteOutline(path)
default:
return fmt.Errorf("unsupported outline action %q", args[0].String())
}
})
}
// styleObjects 原子更新对象透明度和路径描边属性
// 入参: args 页面索引、对象标识数组及样式,省略字段保持原值
// 返回: any 文档信息, error 错误信息
func styleObjects(args []js.Value) (any, error) {
return changeObjects(func() error {
style := ofdgo.ObjectStyle{}
value := args[2]
if field := value.Get("alpha"); !field.IsUndefined() {
v := field.Int()
style.Alpha = &v
}
if field := value.Get("dashPattern"); !field.IsUndefined() {
v := field.String()
style.DashPattern = &v
}
if field := value.Get("dashOffset"); !field.IsUndefined() {
v := field.Float()
style.DashOffset = &v
}
if field := value.Get("cap"); !field.IsUndefined() {
v := field.String()
style.Cap = &v
}
if field := value.Get("join"); !field.IsUndefined() {
v := field.String()
style.Join = &v
}
return currentEditor.StyleObjects(args[0].Int(), stringsFromJS(args[1]), style)
})
}
// clearImport 释放待插页文档
func clearImport() {
if pendingImport != nil {
+142 -68
View File
@@ -50,6 +50,7 @@ type Editor struct {
revision uint64
serial uint64
source *editorSource
outlines []byte
}
// editorResource 文档内嵌资源
@@ -174,70 +175,165 @@ func (e *Editor) AddPage(width, height float64) (int, error) {
// 入参: index 原页面索引
// 返回: int 副本页面索引, error 错误信息
func (e *Editor) CopyPage(index int) (int, error) {
if e.originalPage(index) {
source, err := e.Reader()
if err != nil {
return 0, err
}
at := len(e.pages)
_, err = e.importPages(source, []int{index}, at, true)
return at, err
}
source, err := e.page(index)
indexes, err := e.CopyPages([]int{index})
if err != nil {
return 0, err
}
page := PageContent{Area: source.Area, Content: Content{Layer: make([]Layer, len(source.Content.Layer))}}
for i, layer := range source.Content.Layer {
page.Content.Layer[i] = Layer{Type: layer.Type, Objects: make([]GraphicObject, len(layer.Objects))}
for j, object := range layer.Objects {
page.Content.Layer[i].Objects[j], err = cloneEditorObject(object)
if err != nil {
return 0, err
return indexes[0], nil
}
// CopyPages 按指定顺序复制页面并追加到文档末尾,一次操作计入一条撤销记录
// 入参: indexes 不重复的原页面索引
// 返回: []int 副本页面索引, error 错误信息
func (e *Editor) CopyPages(indexes []int) ([]int, error) {
if err := e.validatePageIndexes(indexes); err != nil {
return nil, err
}
if len(indexes) == 0 {
return nil, nil
}
at := len(e.pages)
if e.source != nil {
source, err := e.Reader()
if err != nil {
return nil, err
}
if _, err := e.importPages(source, indexes, at, true); err != nil {
return nil, err
}
result := make([]int, len(indexes))
for i := range result {
result[i] = at + i
}
return result, nil
}
pages := make([]PageContent, len(indexes))
for n, index := range indexes {
source := &e.pages[index]
page := &pages[n]
*page = PageContent{Area: source.Area, Content: Content{Layer: make([]Layer, len(source.Content.Layer))}}
for i, layer := range source.Content.Layer {
page.Content.Layer[i] = Layer{Type: layer.Type, Objects: make([]GraphicObject, len(layer.Objects))}
for j, object := range layer.Objects {
copy, err := cloneEditorObject(object)
if err != nil {
return nil, err
}
page.Content.Layer[i].Objects[j] = copy
}
}
}
page.ID = e.nextID()
for i := range page.Content.Layer {
layer := &page.Content.Layer[i]
layer.ID = e.nextID()
for j := range layer.Objects {
object := &layer.Objects[j]
origin := e.objectOrigin(editorObjectID(*object))
id := e.nextID()
if origin != nil {
e.source.origins[id] = origin
result := make([]int, len(pages))
for n := range pages {
page := &pages[n]
page.ID = e.nextID()
for i := range page.Content.Layer {
layer := &page.Content.Layer[i]
layer.ID = e.nextID()
for j := range layer.Objects {
object := &layer.Objects[j]
id := e.nextID()
switch object.Type {
case "TextObject":
object.TextObject.ID = id
case "PathObject":
object.PathObject.ID = id
case "ImageObject":
object.ImageObject.ID = id
}
}
switch object.Type {
case "TextObject":
object.TextObject.ID = id
case "PathObject":
object.PathObject.ID = id
case "ImageObject":
object.ImageObject.ID = id
}
result[n] = at + n
}
e.pages = append(e.pages, pages...)
if change := e.recordChange(); change != nil {
for i := range pages {
pages[i] = copyEditorPage(pages[i])
}
change.undo = func(e *Editor) { e.pages = e.pages[:at] }
change.redo = func(e *Editor) {
for _, page := range pages {
e.pages = append(e.pages, copyEditorPage(page))
}
}
}
e.pages = append(e.pages, page)
index = len(e.pages) - 1
e.recordPageAddition(index)
return index, nil
return result, nil
}
// DeletePage 删除页面,输出时清理相关目录、跳转、注释和签章位置,保留其他页面及原始签名凭据
// 入参: index 页面索引
// 返回: error 错误信息
func (e *Editor) DeletePage(index int) error {
page, err := e.page(index)
if err != nil {
return e.DeletePages([]int{index})
}
// DeletePages 删除指定页面,输出时清理页面引用,一次操作计入一条撤销记录
// 入参: indexes 不重复的页面索引
// 返回: error 错误信息
func (e *Editor) DeletePages(indexes []int) error {
if err := e.validatePageIndexes(indexes); err != nil {
return err
}
if len(indexes) == 0 {
return nil
}
indexes = slices.Clone(indexes)
slices.Sort(indexes)
saved := make([]PageContent, len(indexes))
for i, index := range indexes {
page, err := e.page(index)
if err != nil {
return err
}
saved[i] = copyEditorPage(*page)
}
remove := func(e *Editor) {
kept, next := e.pages[:0], 0
for index, page := range e.pages {
if next < len(indexes) && index == indexes[next] {
next++
} else {
kept = append(kept, page)
}
}
clear(e.pages[len(kept):])
e.pages = kept
}
if change := e.recordChange(); change != nil {
saved := copyEditorPage(*page)
change.undo = func(e *Editor) { e.pages = slices.Insert(e.pages, index, copyEditorPage(saved)) }
change.redo = func(e *Editor) { e.pages = slices.Delete(e.pages, index, index+1) }
change.undo = func(e *Editor) {
pages := make([]PageContent, len(e.pages)+len(saved))
kept, removed := 0, 0
for index := range pages {
if removed < len(indexes) && index == indexes[removed] {
pages[index] = copyEditorPage(saved[removed])
removed++
} else {
pages[index] = e.pages[kept]
kept++
}
}
e.pages = pages
}
change.redo = remove
}
remove(e)
return nil
}
// validatePageIndexes 校验页面索引范围和重复值
// 入参: indexes 页面索引
// 返回: error 错误信息
func (e *Editor) validatePageIndexes(indexes []int) error {
seen := make(map[int]bool, len(indexes))
for _, index := range indexes {
if index < 0 || index >= len(e.pages) {
return fmt.Errorf("page index %d out of range", index)
}
if seen[index] {
return fmt.Errorf("duplicate page index %d", index)
}
seen[index] = true
}
e.pages = slices.Delete(e.pages, index, index+1)
return nil
}
@@ -553,30 +649,8 @@ func (e *Editor) prepareObject(id string, object GraphicObject) (GraphicObject,
if obj.Rule != "" && obj.Rule != "NonZero" && obj.Rule != "Even-Odd" {
return GraphicObject{}, fmt.Errorf("invalid fill rule %q", obj.Rule)
}
if !finite(obj.LineWidth) || obj.LineWidth < 0 || !finite(obj.MiterLimit) || obj.MiterLimit < 0 {
return GraphicObject{}, fmt.Errorf("invalid path stroke dimensions")
}
if obj.Cap != "" && obj.Cap != "Butt" && obj.Cap != "Round" && obj.Cap != "Square" {
return GraphicObject{}, fmt.Errorf("invalid line cap %q", obj.Cap)
}
if obj.DashOffset != nil && !finite(*obj.DashOffset) {
return GraphicObject{}, fmt.Errorf("invalid dash offset")
}
if obj.DashPattern != "" {
values, err := creationNumbers(obj.DashPattern, len(strings.Fields(obj.DashPattern)))
if err != nil {
return GraphicObject{}, err
}
total := 0.0
for _, value := range values {
if value < 0 {
return GraphicObject{}, fmt.Errorf("dash lengths must not be negative")
}
total += value
}
if total == 0 {
return GraphicObject{}, fmt.Errorf("dash pattern must have a positive length")
}
if err := validateEditorStroke(*obj); err != nil {
return GraphicObject{}, err
}
obj.ID = id
boundary, ctm, drawParam = obj.Boundary, obj.CTM, obj.DrawParam
+5 -5
View File
@@ -342,10 +342,10 @@ func (e *Editor) UpdateObjects(page int, objects []GraphicObject) error {
return e.updateObjects(page, objects, false)
}
// updateObjects 校验内容更新或纯几何变换,原子提交跨图层选区
// 入参: page 页面索引, objects 新对象, geometry 是否保留原内容的几何操作
// updateObjects 校验内容更新或几何与外观变换,原子提交跨图层选区
// 入参: page 页面索引, objects 新对象, preserveContent 是否保留原内容
// 返回: error 错误信息
func (e *Editor) updateObjects(page int, objects []GraphicObject, geometry bool) error {
func (e *Editor) updateObjects(page int, objects []GraphicObject, preserveContent bool) error {
ids := make([]string, len(objects))
for i, object := range objects {
ids[i] = editorObjectID(object)
@@ -361,11 +361,11 @@ func (e *Editor) updateObjects(page int, objects []GraphicObject, geometry bool)
if err != nil {
return err
}
if !capability.Transform || !geometry && !e.sourceRGB() {
if !capability.Transform || !preserveContent && !e.sourceRGB() {
return fmt.Errorf("object %q is read-only for this operation: %w", ids[i], capability.editError())
}
}
if geometry && e.originalPage(page) {
if preserveContent && e.originalPage(page) {
if err := validateEditorGeometry(object); err != nil {
return err
}
+405
View File
@@ -0,0 +1,405 @@
// 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"
"strconv"
"strings"
)
// Outlines 获取当前目录层级,目标页码从1开始,无目标时为0
// 返回: []OutlineInfo 目录信息, error 错误信息
func (e *Editor) Outlines() ([]OutlineInfo, error) {
data, err := e.outlineXML()
if err != nil {
return nil, err
}
var doc Document
if err := xml.Unmarshal(data, &doc.Outlines); err != nil {
return nil, err
}
if e.source != nil {
doc.Bookmarks = e.source.document.Bookmarks
}
for _, page := range e.pages {
doc.Pages.Page = append(doc.Pages.Page, Page{ID: page.ID})
}
return doc.OutlineInfos(), nil
}
// AddOutline 在指定父目录末尾添加节点,空路径表示根目录
// 入参: parent 从0开始的层级索引, title 标题, page 目标页面索引,-1表示无目标
// 返回: []int 新节点路径, error 错误信息
func (e *Editor) AddOutline(parent []int, title string, page int) ([]int, error) {
if err := e.validateOutline(title, page); err != nil {
return nil, err
}
data, err := e.outlineXML()
if err != nil {
return nil, err
}
root, err := parseEditorXML(data)
if err != nil {
return nil, err
}
node, err := editorOutlineNode(root, parent)
if err != nil {
return nil, err
}
attrs := ofdAttrs{}
attrs.add("Title", title)
item, err := editorXMLContainer("OutlineElem", attrs, e.outlineAction(page))
if err != nil {
return nil, err
}
var content []byte
if node.open != node.end {
content = bytes.Clone(data[node.open:node.close])
}
content = append(content, item...)
result := append(slices.Clone(parent), len(editorOutlineChildren(node)))
err = e.setOutlineXML(editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, node, content)}))
return result, err
}
// UpdateOutline 修改目录标题和目标,目标未改变时保留原跳转坐标及动作
// 入参: path 从0开始的层级索引, title 标题, page 目标页面索引,-1表示无目标
// 返回: error 错误信息
func (e *Editor) UpdateOutline(path []int, title string, page int) error {
if err := e.validateOutline(title, page); err != nil {
return err
}
data, err := e.outlineXML()
if err != nil {
return err
}
root, err := parseEditorXML(data)
if err != nil {
return err
}
node, err := editorOutlineNode(root, path)
if err != nil {
return err
}
if len(path) == 0 {
return fmt.Errorf("outline path must not be empty")
}
infos, err := e.Outlines()
if err != nil {
return err
}
var info OutlineInfo
for _, index := range path {
info = infos[index]
infos = info.Children
}
var patches []editorXMLPatch
if info.Page != page+1 {
actions := node.child("Actions")
if actions != nil {
var removals []editorXMLPatch
for _, action := range actions.children {
if action.name.Space != actions.name.Space && action.name.Space != ofdNamespace && action.name.Space != "" {
continue
}
if action.name.Local == "Action" && action.child("Goto") != nil {
removals = append(removals, editorXMLPatch{action.start - actions.open, action.end - actions.open, nil})
}
}
var content []byte
if actions.open != actions.end {
content = editorPatchXML(data[actions.open:actions.close], removals)
}
added := e.outlineAction(page)
if len(added) > 0 {
addRoot, err := parseEditorXML(added)
if err != nil {
return err
}
action := addRoot.children[0]
standalone, err := editorXMLStandalone(added[action.start:action.end], action)
if err != nil {
return err
}
content = append(content, standalone...)
}
patches = append(patches, editorXMLContent(data, actions, content))
} else if page >= 0 {
var content []byte
content = append(content, e.outlineAction(page)...)
if node.open != node.end {
content = append(content, data[node.open:node.close]...)
}
patches = append(patches, editorXMLContent(data, node, content))
}
}
updated := editorPatchXML(data, patches)
root, err = parseEditorXML(updated)
if err != nil {
return err
}
node, err = editorOutlineNode(root, path)
if err != nil {
return err
}
value, err := editorOutlineAttribute(updated, node, "Title", title)
if err != nil {
return err
}
return e.setOutlineXML(editorPatchXML(updated, []editorXMLPatch{{node.start, node.end, value}}))
}
// DeleteOutline 删除目录节点及其子节点
// 入参: path 从0开始的层级索引
// 返回: error 错误信息
func (e *Editor) DeleteOutline(path []int) error {
if len(path) == 0 {
return fmt.Errorf("outline path must not be empty")
}
data, err := e.outlineXML()
if err != nil {
return err
}
root, err := parseEditorXML(data)
if err != nil {
return err
}
node, err := editorOutlineNode(root, path)
if err != nil {
return err
}
return e.setOutlineXML(editorPatchXML(data, []editorXMLPatch{{node.start, node.end, nil}}))
}
// validateOutline 校验目录标题和目标页面
// 入参: title 标题, page 页面索引
// 返回: error 错误信息
func (e *Editor) validateOutline(title string, page int) error {
if strings.TrimSpace(title) == "" {
return fmt.Errorf("outline title must not be empty")
}
if page < -1 || page >= len(e.pages) {
return fmt.Errorf("page index %d out of range", page)
}
return nil
}
// outlineXML 获取保留命名空间的目录原文
// 返回: []byte XML内容, error 错误信息
func (e *Editor) outlineXML() ([]byte, error) {
if e.outlines != nil {
return e.outlines, nil
}
if e.source != nil {
reader := e.source.reader
data, err := reader.readFile(reader.ResPath(reader.OFD.DocBody[0].DocRoot))
if err != nil {
return nil, err
}
root, err := parseEditorXML(data)
if err != nil {
return nil, err
}
if node := root.child("Outlines"); node != nil {
return editorXMLStandalone(data[node.start:node.end], node)
}
}
return editorXMLContainer("Outlines", nil, nil)
}
// outlineAction 编码页面适配跳转,负数表示无目标
// 入参: page 页面索引
// 返回: []byte 动作集合
func (e *Editor) outlineAction(page int) []byte {
if page < 0 {
return nil
}
data, _ := encodeOFDXML(func(x *ofdXML) {
x.root("Actions", nil)
x.start("Action", ofdAttrs{{Name: xml.Name{Local: "Event"}, Value: "CLICK"}})
x.start("Goto", nil)
x.start("Dest", ofdAttrs{{Name: xml.Name{Local: "Type"}, Value: "Fit"}, {Name: xml.Name{Local: "PageID"}, Value: e.pages[page].ID}})
x.end("Dest")
x.end("Goto")
x.end("Action")
x.end("Actions")
})
return bytes.TrimPrefix(data, []byte(xml.Header))
}
// editorOutlineChildren 获取标准目录子节点
// 入参: node 父节点
// 返回: []*editorXML 子节点
func editorOutlineChildren(node *editorXML) []*editorXML {
var children []*editorXML
for _, child := range node.children {
if child.name.Local == "OutlineElem" && (child.name.Space == node.name.Space || child.name.Space == ofdNamespace || child.name.Space == "") {
children = append(children, child)
}
}
return children
}
// editorOutlineNode 按层级路径定位目录
// 入参: root 目录根节点, path 层级索引
// 返回: *editorXML 节点, error 错误信息
func editorOutlineNode(root *editorXML, path []int) (*editorXML, error) {
for _, index := range path {
children := editorOutlineChildren(root)
if index < 0 || index >= len(children) {
return nil, fmt.Errorf("outline index %d out of range", index)
}
root = children[index]
}
return root, nil
}
// editorOutlineAttribute 修改单个标准属性,保留节点其余原文
// 入参: data 原文, node 节点, name 属性名, value 新值
// 返回: []byte 修改后的节点, error 错误信息
func editorOutlineAttribute(data []byte, node *editorXML, name, value string) ([]byte, error) {
before, err := editorXMLContainer(node.name.Local, ofdAttrs{{Name: xml.Name{Local: name}, Value: node.attr(name)}}, nil)
if err != nil {
return nil, err
}
after, err := editorXMLContainer(node.name.Local, ofdAttrs{{Name: xml.Name{Local: name}, Value: value}}, nil)
if err != nil {
return nil, err
}
return editorXMLMerge(data, node, before, after)
}
// setOutlineXML 更新目录及可选叶节点计数,保存一次撤销记录
// 入参: data 新目录原文
// 返回: error 错误信息
func (e *Editor) setOutlineXML(data []byte) error {
root, err := parseEditorXML(data)
if err != nil {
return err
}
var patches []editorXMLPatch
var leaves func(*editorXML) int
leaves = func(node *editorXML) int {
children := editorOutlineChildren(node)
if len(children) == 0 {
return 1
}
count := 0
for _, child := range children {
count += leaves(child)
}
return count
}
var walk func(*editorXML) error
walk = func(node *editorXML) error {
if node.attr("Count") != "" {
count := 0
for _, child := range editorOutlineChildren(node) {
count += leaves(child)
}
value, err := editorOutlineAttribute(data, node, "Count", strconv.Itoa(count))
if err != nil {
return err
}
updated, err := parseEditorXML(value)
if err != nil {
return err
}
if node.open != node.end {
patches = append(patches, editorXMLPatch{node.start, node.open, value[:updated.open]})
} else {
patches = append(patches, editorXMLPatch{node.start, node.end, value})
}
}
for _, child := range editorOutlineChildren(node) {
if err := walk(child); err != nil {
return err
}
}
return nil
}
if err := walk(root); err != nil {
return err
}
data = editorPatchXML(data, patches)
current, err := e.outlineXML()
if err != nil {
return err
}
if bytes.Equal(current, data) {
return nil
}
before := e.outlines
e.outlines = data
if change := e.recordChange(); change != nil {
change.undo = func(e *Editor) { e.outlines = before }
change.redo = func(e *Editor) { e.outlines = data }
}
return nil
}
// withOutlines 将修改后的目录写入文档,清除指向已删除页面的新增跳转
// 入参: data 文档原文
// 返回: []byte 更新文档, error 错误信息
func (e *Editor) withOutlines(data []byte) ([]byte, error) {
if e.outlines == nil {
return data, nil
}
outline, err := parseEditorXML(e.outlines)
if err != nil {
return nil, err
}
valid := make(map[string]bool, len(e.pages))
for _, page := range e.pages {
valid[page.ID] = true
}
var removals []editorXMLPatch
var walk func(*editorXML)
walk = func(node *editorXML) {
if actions := node.child("Actions"); actions != nil {
for _, action := range actions.children {
if action.name.Local != "Action" || action.name.Space != actions.name.Space && action.name.Space != ofdNamespace && action.name.Space != "" {
continue
}
if target := action.child("Goto"); target != nil {
if dest := target.child("Dest"); dest != nil && !valid[dest.attr("PageID")] {
removals = append(removals, editorXMLPatch{action.start, action.end, nil})
}
}
}
}
for _, child := range editorOutlineChildren(node) {
walk(child)
}
}
walk(outline)
value := editorPatchXML(e.outlines, removals)
root, err := parseEditorXML(data)
if err != nil {
return nil, err
}
if node := root.child("Outlines"); node != nil {
return editorPatchXML(data, []editorXMLPatch{{node.start, node.end, value}}), nil
}
pages := root.child("Pages")
if pages == nil {
return nil, fmt.Errorf("document has no Pages")
}
return editorPatchXML(data, []editorXMLPatch{{pages.end, pages.end, value}}), nil
}
+16
View File
@@ -159,6 +159,22 @@ func (e *Editor) sourceParts() (map[string][]byte, error) {
}
parts["OFD.xml"] = data
}
if e.outlines != nil {
name := reader.ResPath(reader.OFD.DocBody[0].DocRoot)
data, ok := parts[name]
if !ok {
var err error
data, err = reader.readFile(name)
if err != nil {
return nil, err
}
}
updated, err := e.withOutlines(data)
if err != nil {
return nil, err
}
parts[name] = updated
}
if err := e.prunePageReferences(parts); err != nil {
return nil, err
}
+105 -1
View File
@@ -14,7 +14,10 @@
package ofdgo
import "fmt"
import (
"fmt"
"strings"
)
// editorDrawParam 解析原文档的绘制参数继承链,缺失或循环引用不进入编辑快照
// 入参: id 绘制参数标识, visited 已访问标识
@@ -89,3 +92,104 @@ func (e *Editor) resolveEditorStyle(object GraphicObject, layer string) (Graphic
}
return cloneEditorObject(object)
}
// ObjectStyle 对象外观,nil字段保持原值,长度单位为对象坐标系中的毫米
type ObjectStyle struct {
Alpha *int
DashPattern *string
DashOffset *float64
Cap *string
Join *string
}
// StyleObjects 原子更新文字、图片和路径透明度,路径另支持描边样式
// 入参: page 页面索引, ids 对象标识, style 待修改属性
// 返回: error 错误信息
func (e *Editor) StyleObjects(page int, ids []string, style ObjectStyle) error {
if style.Alpha != nil && (*style.Alpha < 0 || *style.Alpha > 255) {
return fmt.Errorf("alpha must be between 0 and 255")
}
objects, _, err := e.selectedObjects(page, ids)
if err != nil {
return err
}
stroke := style.DashPattern != nil || style.DashOffset != nil || style.Cap != nil || style.Join != nil
for i := range objects {
object := cloneEditorData(objects[i])
if stroke && object.Type != "PathObject" {
return fmt.Errorf("stroke style requires path objects")
}
switch object.Type {
case "TextObject":
if style.Alpha != nil {
object.TextObject.Alpha = style.Alpha
}
case "ImageObject":
if style.Alpha != nil {
object.ImageObject.Alpha = style.Alpha
}
case "PathObject":
path := &object.PathObject
if style.Alpha != nil {
path.Alpha = style.Alpha
}
if style.DashPattern != nil {
path.DashPattern = *style.DashPattern
}
if style.DashOffset != nil {
path.DashOffset = style.DashOffset
}
if style.Cap != nil {
path.Cap = *style.Cap
}
if style.Join != nil {
path.Join = *style.Join
}
if stroke {
if err := validateEditorStroke(*path); err != nil {
return err
}
if path.Join != "" && path.Join != "Miter" && path.Join != "Round" && path.Join != "Bevel" {
return fmt.Errorf("invalid line join %q", path.Join)
}
}
default:
return fmt.Errorf("unsupported object type %q", object.Type)
}
objects[i] = object
}
return e.updateObjects(page, objects, true)
}
// validateEditorStroke 校验路径描边尺寸、端点和虚线
// 入参: path 路径对象
// 返回: error 错误信息
func validateEditorStroke(path PathObject) error {
if !finite(path.LineWidth) || path.LineWidth < 0 || !finite(path.MiterLimit) || path.MiterLimit < 0 {
return fmt.Errorf("invalid path stroke dimensions")
}
if path.Cap != "" && path.Cap != "Butt" && path.Cap != "Round" && path.Cap != "Square" {
return fmt.Errorf("invalid line cap %q", path.Cap)
}
if path.DashOffset != nil && !finite(*path.DashOffset) {
return fmt.Errorf("invalid dash offset")
}
if path.DashPattern == "" {
return nil
}
values, err := creationNumbers(path.DashPattern, len(strings.Fields(path.DashPattern)))
if err != nil {
return err
}
total := 0.0
for _, value := range values {
if value < 0 {
return fmt.Errorf("dash lengths must not be negative")
}
total += value
}
if total == 0 {
return fmt.Errorf("dash pattern must have a positive length")
}
return nil
}
+22
View File
@@ -19,8 +19,30 @@ import (
"math"
"strconv"
"strings"
"github.com/tdewolff/canvas"
)
// Outline 获取页面坐标系中的SVG路径轮廓,不包含描边宽度和填充
// 入参: p 路径对象
// 返回: string SVG路径数据, error 错误信息
func (p PathObject) Outline() (string, error) {
if err := creationPath(p.AbbreviatedData); err != nil {
return "", err
}
if _, err := creationBox(p.Boundary); err != nil {
return "", err
}
if p.CTM != "" {
if _, err := creationNumbers(p.CTM, 6); err != nil {
return "", err
}
}
r := &Renderer{}
path := r.buildPath(p, 0, NewMatrix(p.CTM), false)
return path.Transform(canvas.Matrix{{1, 0, 0}, {0, -1, 0}}).ToSVG(), nil
}
// ShapeKind 基本图形类型
type ShapeKind string
+6
View File
@@ -129,6 +129,12 @@ func (e *Editor) writeParts(write func(string, []byte, bool) error) error {
if err != nil {
return err
}
if name == "Doc_0/Document.xml" {
data, err = e.withOutlines(data)
if err != nil {
return err
}
}
return write(name, data, false)
}
if err := writeXML("OFD.xml", func(x *ofdXML) {