mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 08:46:55 +08:00
This commit is contained in:
1 parent
ee48ca666d
commit
4a6174c2ae
12 files changed
+1249
-101
No files matched your search
+32
-2
@@ -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">
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user