mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 14:31:12 +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="horizontal">水平</option>
|
||||||
<option value="vertical">垂直</option>
|
<option value="vertical">垂直</option>
|
||||||
</select>
|
</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>
|
||||||
<div class="editor-group editor-erase">
|
<div class="editor-group editor-erase">
|
||||||
<button id="eraseObjectButton" class="button" type="button" title="整对象擦除" aria-label="整对象擦除" aria-pressed="false">对象</button>
|
<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="movePagePrevButton" class="button" type="button" aria-label="页面前移">前移</button>
|
||||||
<button id="movePageNextButton" 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>
|
<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>
|
||||||
</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="新建文档">
|
<dialog id="createPanel" class="form-dialog" aria-label="新建文档">
|
||||||
<form id="createForm">
|
<form id="createForm">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
|||||||
@@ -1470,6 +1470,95 @@ button.outline-link:hover {
|
|||||||
cursor: default;
|
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 {
|
.edit-line::before {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: calc(-4px / var(--surface-scale));
|
inset: calc(-4px / var(--surface-scale));
|
||||||
|
|||||||
+277
-13
@@ -39,6 +39,9 @@ const state = {
|
|||||||
savedRevision: null,
|
savedRevision: null,
|
||||||
insertObject: null,
|
insertObject: null,
|
||||||
importPageCount: 0,
|
importPageCount: 0,
|
||||||
|
outlineSelection: null,
|
||||||
|
outlineAction: "add",
|
||||||
|
styleOriginal: null,
|
||||||
objectClipboard: null,
|
objectClipboard: null,
|
||||||
textFonts: [],
|
textFonts: [],
|
||||||
textFontID: null,
|
textFontID: null,
|
||||||
@@ -164,6 +167,35 @@ const el = {
|
|||||||
movePagePrevButton: document.querySelector("#movePagePrevButton"),
|
movePagePrevButton: document.querySelector("#movePagePrevButton"),
|
||||||
movePageNextButton: document.querySelector("#movePageNextButton"),
|
movePageNextButton: document.querySelector("#movePageNextButton"),
|
||||||
pageSettingsButton: document.querySelector("#pageSettingsButton"),
|
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"),
|
pagePanel: document.querySelector("#pagePanel"),
|
||||||
pageForm: document.querySelector("#pageForm"),
|
pageForm: document.querySelector("#pageForm"),
|
||||||
pagePortrait: document.querySelector("#pagePortrait"),
|
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.movePagePrevButton, () => changeDocument("ofdgoChangePage", null, "move", state.pageIndex, state.pageIndex - 1));
|
||||||
editorClick(el.movePageNextButton, () => 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.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.pageCancel.addEventListener("click", () => el.pagePanel.close());
|
||||||
el.pageWidth.addEventListener("input", updatePageDirection);
|
el.pageWidth.addEventListener("input", updatePageDirection);
|
||||||
el.pageHeight.addEventListener("input", updatePageDirection);
|
el.pageHeight.addEventListener("input", updatePageDirection);
|
||||||
@@ -797,7 +864,73 @@ function handleKeyDown(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formDialogOpen() {
|
function formDialogOpen() {
|
||||||
return el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open;
|
return el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open
|
||||||
|
|| el.batchPagesPanel.open || el.objectStylePanel.open || el.outlinePanel.open;
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
function openImportPanel() {
|
||||||
@@ -899,6 +1032,8 @@ async function toggleEditor() {
|
|||||||
setPan(false);
|
setPan(false);
|
||||||
}
|
}
|
||||||
updateControls();
|
updateControls();
|
||||||
|
renderOutlines(false);
|
||||||
|
renderPageList();
|
||||||
applyFit(false);
|
applyFit(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1432,6 +1567,13 @@ async function changeDocument(name, item, ...args) {
|
|||||||
updateControls();
|
updateControls();
|
||||||
return true;
|
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);
|
const clearSelection = !item || ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoEraseObjects", "ofdgoEraseObjectsPath"].includes(name);
|
||||||
if (name === "ofdgoCopyObjects" || name === "ofdgoPasteObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") {
|
if (name === "ofdgoCopyObjects" || name === "ofdgoPasteObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") {
|
||||||
state.selectObjects = true;
|
state.selectObjects = true;
|
||||||
@@ -1457,7 +1599,13 @@ async function changeDocument(name, item, ...args) {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (openSeq === state.openSeq) {
|
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;
|
el.infoStatus.textContent = err.message;
|
||||||
} else if (el.pagePanel.open) {
|
} else if (el.pagePanel.open) {
|
||||||
el.pageStatus.textContent = err.message;
|
el.pageStatus.textContent = err.message;
|
||||||
@@ -1984,6 +2132,9 @@ async function openOFD(file) {
|
|||||||
el.paragraphPanel.close();
|
el.paragraphPanel.close();
|
||||||
el.infoPanel.close();
|
el.infoPanel.close();
|
||||||
el.importPanel.close();
|
el.importPanel.close();
|
||||||
|
el.batchPagesPanel.close();
|
||||||
|
el.objectStylePanel.close();
|
||||||
|
el.outlinePanel.close();
|
||||||
updateControls();
|
updateControls();
|
||||||
await openDocument({ pageIndex: 0, resetScroll: true, openSeq });
|
await openDocument({ pageIndex: 0, resetScroll: true, openSeq });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -2439,7 +2590,7 @@ async function openDocument(options = {}) {
|
|||||||
resetPageFlow(options.keepPreview);
|
resetPageFlow(options.keepPreview);
|
||||||
renderPageList();
|
renderPageList();
|
||||||
if (options.keepPreview || options.resetScroll || el.outlineList.childElementCount === 0) {
|
if (options.keepPreview || options.resetScroll || el.outlineList.childElementCount === 0) {
|
||||||
renderOutlines();
|
renderOutlines(!options.keepPreview);
|
||||||
}
|
}
|
||||||
renderMeta();
|
renderMeta();
|
||||||
renderPageFlow();
|
renderPageFlow();
|
||||||
@@ -3514,18 +3665,36 @@ function showNavigation(selected) {
|
|||||||
el.pageListPanel.scrollTop = state.navigationScroll.get(selected) || 0;
|
el.pageListPanel.scrollTop = state.navigationScroll.get(selected) || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderOutlines() {
|
function renderOutlines(reset = true) {
|
||||||
state.navigationScroll.clear();
|
const selected = !reset && [el.pagesTab, el.outlinesTab, el.searchTab].find(tab => tab.getAttribute("aria-selected") === "true");
|
||||||
el.pageListPanel.scrollTop = 0;
|
const scrollTop = reset ? 0 : el.pageListPanel.scrollTop;
|
||||||
|
if (reset) state.navigationScroll.clear();
|
||||||
const outlines = state.doc.outlines || [];
|
const outlines = state.doc.outlines || [];
|
||||||
el.pageListTitle.hidden = true;
|
el.pageListTitle.hidden = true;
|
||||||
el.navigationTabs.hidden = false;
|
el.navigationTabs.hidden = false;
|
||||||
el.outlinesTab.hidden = outlines.length === 0;
|
el.outlinesTab.hidden = outlines.length === 0 && !state.editing;
|
||||||
el.outlineList.replaceChildren();
|
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) {
|
if (outlines.length > 0) {
|
||||||
el.outlineList.append(createOutlineList(outlines));
|
el.outlineList.append(createOutlineList(outlines));
|
||||||
}
|
}
|
||||||
showNavigation(el.pagesTab);
|
el.pageListPanel.scrollTop = scrollTop;
|
||||||
|
showNavigation(selected && !selected.hidden ? selected : el.pagesTab);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetSearch(clearInput = true) {
|
function resetSearch(clearInput = true) {
|
||||||
@@ -3703,17 +3872,18 @@ function renderSearchHighlights(index) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createOutlineList(outlines) {
|
function createOutlineList(outlines, path = []) {
|
||||||
const list = document.createElement("ul");
|
const list = document.createElement("ul");
|
||||||
list.className = "outline-list";
|
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 item = document.createElement("li");
|
||||||
const hasChildren = outline.children?.length > 0;
|
const hasChildren = outline.children?.length > 0;
|
||||||
const row = document.createElement(hasChildren ? "summary" : "div");
|
const row = document.createElement(hasChildren ? "summary" : "div");
|
||||||
if (!hasChildren) {
|
if (!hasChildren) {
|
||||||
row.className = "outline-leaf";
|
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";
|
link.className = "outline-link";
|
||||||
const title = document.createElement("span");
|
const title = document.createElement("span");
|
||||||
title.textContent = outline.title;
|
title.textContent = outline.title;
|
||||||
@@ -3725,16 +3895,24 @@ function createOutlineList(outlines) {
|
|||||||
page.textContent = String(outline.page);
|
page.textContent = String(outline.page);
|
||||||
page.setAttribute("aria-label", `第 ${outline.page} 页`);
|
page.setAttribute("aria-label", `第 ${outline.page} 页`);
|
||||||
link.append(page);
|
link.append(page);
|
||||||
|
}
|
||||||
|
if (outline.page || state.editing) {
|
||||||
|
link.type = "button";
|
||||||
link.addEventListener("click", (event) => {
|
link.addEventListener("click", (event) => {
|
||||||
event.preventDefault();
|
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);
|
row.append(link);
|
||||||
if (hasChildren) {
|
if (hasChildren) {
|
||||||
const details = document.createElement("details");
|
const details = document.createElement("details");
|
||||||
details.open = outline.expanded;
|
details.open = outline.expanded;
|
||||||
details.append(row, createOutlineList(outline.children));
|
details.append(row, createOutlineList(outline.children, current));
|
||||||
item.append(details);
|
item.append(details);
|
||||||
} else {
|
} else {
|
||||||
item.append(row);
|
item.append(row);
|
||||||
@@ -3778,6 +3956,7 @@ function renderPageList() {
|
|||||||
size.textContent = `${formatSize(page.width)} x ${formatSize(page.height)} mm`;
|
size.textContent = `${formatSize(page.width)} x ${formatSize(page.height)} mm`;
|
||||||
|
|
||||||
button.append(thumb, label, size);
|
button.append(thumb, label, size);
|
||||||
|
if (state.editing && pageCan("move", page.index)) enablePageDrag(button, page.index);
|
||||||
button.addEventListener("click", () => renderPage(page.index));
|
button.addEventListener("click", () => renderPage(page.index));
|
||||||
fragment.append(button);
|
fragment.append(button);
|
||||||
thumbnailTargets.push([button, page.index]);
|
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) {
|
function layoutThumbnail(button, page) {
|
||||||
if (page.width <= 0 || page.height <= 0) {
|
if (page.width <= 0 || page.height <= 0) {
|
||||||
return;
|
return;
|
||||||
@@ -4665,6 +4921,7 @@ function updateEditorTools() {
|
|||||||
const pagesDisabled = !state.editing || !state.ready || state.exporting;
|
const pagesDisabled = !state.editing || !state.ready || state.exporting;
|
||||||
el.insertTextButton.disabled = el.insertImageButton.disabled = pagesDisabled || !pageCan("insert");
|
el.insertTextButton.disabled = el.insertImageButton.disabled = pagesDisabled || !pageCan("insert");
|
||||||
el.saveButton.disabled = el.addPageButton.disabled = pagesDisabled;
|
el.saveButton.disabled = el.addPageButton.disabled = pagesDisabled;
|
||||||
|
el.batchPagesButton.disabled = pagesDisabled;
|
||||||
el.copyPageButton.disabled = pagesDisabled || !pageCan("copy");
|
el.copyPageButton.disabled = pagesDisabled || !pageCan("copy");
|
||||||
el.pageSettingsButton.disabled = pagesDisabled || !pageCan("resize");
|
el.pageSettingsButton.disabled = pagesDisabled || !pageCan("resize");
|
||||||
el.deletePageButton.disabled = pagesDisabled || !pageCan("delete") || state.doc?.pageCount <= 1;
|
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;
|
const disabled = !item || Boolean(item.draft) || !state.ready || state.exporting;
|
||||||
el.deleteObjectButton.disabled = disabled || !canEditObject(item, "delete");
|
el.deleteObjectButton.disabled = disabled || !canEditObject(item, "delete");
|
||||||
el.copyObjectButton.disabled = disabled || !canEditObject(item, "copy");
|
el.copyObjectButton.disabled = disabled || !canEditObject(item, "copy");
|
||||||
|
el.objectStyleButton.disabled = disabled || !canEditObject(item, "transform");
|
||||||
const cropping = Boolean(canvasEditor.crop);
|
const cropping = Boolean(canvasEditor.crop);
|
||||||
el.objectAlign.disabled = disabled || cropping || !canEditObject(item, "arrange");
|
el.objectAlign.disabled = disabled || cropping || !canEditObject(item, "arrange");
|
||||||
el.objectRotate.disabled = el.objectFlip.disabled = el.objectAlign.disabled;
|
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.paragraphForm.inert = busy;
|
||||||
el.infoForm.inert = busy;
|
el.infoForm.inert = busy;
|
||||||
el.importForm.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.editorTools.inert = busy;
|
||||||
el.fontList.inert = busy;
|
el.fontList.inert = busy;
|
||||||
if (!busy) {
|
if (!busy) {
|
||||||
|
|||||||
@@ -249,6 +249,18 @@ export class CanvasEditor {
|
|||||||
node.setAttribute("role", "button");
|
node.setAttribute("role", "button");
|
||||||
node.setAttribute("aria-label", { ImageObject: "图片对象", TextObject: "文字对象", PathObject: "图形对象" }[object.type]);
|
node.setAttribute("aria-label", { ImageObject: "图片对象", TextObject: "文字对象", PathObject: "图形对象" }[object.type]);
|
||||||
const item = { ...object, index, page: { width: page.width, height: page.height }, node, surface };
|
const item = { ...object, index, page: { width: page.width, height: page.height }, node, surface };
|
||||||
|
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);
|
this.nodes.set(node, item);
|
||||||
node.addEventListener("focus", () => this.select(item));
|
node.addEventListener("focus", () => this.select(item));
|
||||||
const handles = !canEditObject(object, "transform") ? [] : object.shape === "line" ? ["start", "end"]
|
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`,
|
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`,
|
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) {
|
if (item.textFrame) {
|
||||||
const { matrix: [a, b, c, d, e, f], width, height } = item.textFrame;
|
const { matrix: [a, b, c, d, e, f], width, height } = item.textFrame;
|
||||||
for (const handle of item.node.children) {
|
for (const handle of item.node.children) {
|
||||||
@@ -317,11 +330,21 @@ export class CanvasEditor {
|
|||||||
});
|
});
|
||||||
if (item.shape === "line") {
|
if (item.shape === "line") {
|
||||||
for (const handle of item.node.children) {
|
for (const handle of item.node.children) {
|
||||||
|
if (!handle.dataset.corner) continue;
|
||||||
const end = handle.dataset.corner === "end";
|
const end = handle.dataset.corner === "end";
|
||||||
handle.style.left = `${(box.x + (end ? box.width : 0) - x) * PX_PER_MM}px`;
|
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`;
|
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() {
|
items() {
|
||||||
|
|||||||
+127
-12
@@ -110,6 +110,9 @@ func RunWASM() {
|
|||||||
registerCallback("ofdgoEditDocument", editDocument)
|
registerCallback("ofdgoEditDocument", editDocument)
|
||||||
registerCallback("ofdgoUpdateInfo", updateInfo)
|
registerCallback("ofdgoUpdateInfo", updateInfo)
|
||||||
registerCallback("ofdgoChangePage", changePage)
|
registerCallback("ofdgoChangePage", changePage)
|
||||||
|
registerCallback("ofdgoBatchPages", batchPages)
|
||||||
|
registerCallback("ofdgoChangeOutline", changeOutline)
|
||||||
|
registerCallback("ofdgoStyleObjects", styleObjects)
|
||||||
registerCallback("ofdgoLoadImport", loadImport)
|
registerCallback("ofdgoLoadImport", loadImport)
|
||||||
registerCallback("ofdgoImportPages", importPages)
|
registerCallback("ofdgoImportPages", importPages)
|
||||||
registerCallback("ofdgoInsertText", insertText)
|
registerCallback("ofdgoInsertText", insertText)
|
||||||
@@ -722,6 +725,40 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
|
|||||||
if missing := capability.MissingGlyphs; missing != nil {
|
if missing := capability.MissingGlyphs; missing != nil {
|
||||||
item["capabilities"].(map[string]any)["missingGlyphs"] = map[string]any{"fontID": missing.FontID, "characters": missing.Characters}
|
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 {
|
if object.Type == "ImageObject" && capability.Update {
|
||||||
full, err := object.ImageObject.ImageBounds()
|
full, err := object.ImageObject.ImageBounds()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -729,18 +766,6 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
|
|||||||
}
|
}
|
||||||
item["imageBounds"] = editorBox(full)
|
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" {
|
if object.Type == "TextObject" {
|
||||||
value, layout := object.TextObject.TextLayout()
|
value, layout := object.TextObject.TextLayout()
|
||||||
item["text"], item["font"], item["fontName"], item["size"] = value, object.TextObject.Font, fontNames[object.TextObject.Font], object.TextObject.Size
|
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
|
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 释放待插页文档
|
// clearImport 释放待插页文档
|
||||||
func clearImport() {
|
func clearImport() {
|
||||||
if pendingImport != nil {
|
if pendingImport != nil {
|
||||||
|
|||||||
+142
-68
@@ -50,6 +50,7 @@ type Editor struct {
|
|||||||
revision uint64
|
revision uint64
|
||||||
serial uint64
|
serial uint64
|
||||||
source *editorSource
|
source *editorSource
|
||||||
|
outlines []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// editorResource 文档内嵌资源
|
// editorResource 文档内嵌资源
|
||||||
@@ -174,70 +175,165 @@ func (e *Editor) AddPage(width, height float64) (int, error) {
|
|||||||
// 入参: index 原页面索引
|
// 入参: index 原页面索引
|
||||||
// 返回: int 副本页面索引, error 错误信息
|
// 返回: int 副本页面索引, error 错误信息
|
||||||
func (e *Editor) CopyPage(index int) (int, error) {
|
func (e *Editor) CopyPage(index int) (int, error) {
|
||||||
if e.originalPage(index) {
|
indexes, err := e.CopyPages([]int{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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
page := PageContent{Area: source.Area, Content: Content{Layer: make([]Layer, len(source.Content.Layer))}}
|
return indexes[0], nil
|
||||||
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 {
|
// CopyPages 按指定顺序复制页面并追加到文档末尾,一次操作计入一条撤销记录
|
||||||
page.Content.Layer[i].Objects[j], err = cloneEditorObject(object)
|
// 入参: indexes 不重复的原页面索引
|
||||||
if err != nil {
|
// 返回: []int 副本页面索引, error 错误信息
|
||||||
return 0, err
|
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()
|
result := make([]int, len(pages))
|
||||||
for i := range page.Content.Layer {
|
for n := range pages {
|
||||||
layer := &page.Content.Layer[i]
|
page := &pages[n]
|
||||||
layer.ID = e.nextID()
|
page.ID = e.nextID()
|
||||||
for j := range layer.Objects {
|
for i := range page.Content.Layer {
|
||||||
object := &layer.Objects[j]
|
layer := &page.Content.Layer[i]
|
||||||
origin := e.objectOrigin(editorObjectID(*object))
|
layer.ID = e.nextID()
|
||||||
id := e.nextID()
|
for j := range layer.Objects {
|
||||||
if origin != nil {
|
object := &layer.Objects[j]
|
||||||
e.source.origins[id] = origin
|
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":
|
result[n] = at + n
|
||||||
object.TextObject.ID = id
|
}
|
||||||
case "PathObject":
|
e.pages = append(e.pages, pages...)
|
||||||
object.PathObject.ID = id
|
if change := e.recordChange(); change != nil {
|
||||||
case "ImageObject":
|
for i := range pages {
|
||||||
object.ImageObject.ID = id
|
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)
|
return result, nil
|
||||||
index = len(e.pages) - 1
|
|
||||||
e.recordPageAddition(index)
|
|
||||||
return index, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeletePage 删除页面,输出时清理相关目录、跳转、注释和签章位置,保留其他页面及原始签名凭据
|
// DeletePage 删除页面,输出时清理相关目录、跳转、注释和签章位置,保留其他页面及原始签名凭据
|
||||||
// 入参: index 页面索引
|
// 入参: index 页面索引
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
func (e *Editor) DeletePage(index int) error {
|
func (e *Editor) DeletePage(index int) error {
|
||||||
page, err := e.page(index)
|
return e.DeletePages([]int{index})
|
||||||
if err != nil {
|
}
|
||||||
|
|
||||||
|
// DeletePages 删除指定页面,输出时清理页面引用,一次操作计入一条撤销记录
|
||||||
|
// 入参: indexes 不重复的页面索引
|
||||||
|
// 返回: error 错误信息
|
||||||
|
func (e *Editor) DeletePages(indexes []int) error {
|
||||||
|
if err := e.validatePageIndexes(indexes); err != nil {
|
||||||
return err
|
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 {
|
if change := e.recordChange(); change != nil {
|
||||||
saved := copyEditorPage(*page)
|
change.undo = func(e *Editor) {
|
||||||
change.undo = func(e *Editor) { e.pages = slices.Insert(e.pages, index, copyEditorPage(saved)) }
|
pages := make([]PageContent, len(e.pages)+len(saved))
|
||||||
change.redo = func(e *Editor) { e.pages = slices.Delete(e.pages, index, index+1) }
|
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
|
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" {
|
if obj.Rule != "" && obj.Rule != "NonZero" && obj.Rule != "Even-Odd" {
|
||||||
return GraphicObject{}, fmt.Errorf("invalid fill rule %q", obj.Rule)
|
return GraphicObject{}, fmt.Errorf("invalid fill rule %q", obj.Rule)
|
||||||
}
|
}
|
||||||
if !finite(obj.LineWidth) || obj.LineWidth < 0 || !finite(obj.MiterLimit) || obj.MiterLimit < 0 {
|
if err := validateEditorStroke(*obj); err != nil {
|
||||||
return GraphicObject{}, fmt.Errorf("invalid path stroke dimensions")
|
return GraphicObject{}, err
|
||||||
}
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
obj.ID = id
|
obj.ID = id
|
||||||
boundary, ctm, drawParam = obj.Boundary, obj.CTM, obj.DrawParam
|
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)
|
return e.updateObjects(page, objects, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateObjects 校验内容更新或纯几何变换,原子提交跨图层选区
|
// updateObjects 校验内容更新或几何与外观变换,原子提交跨图层选区
|
||||||
// 入参: page 页面索引, objects 新对象, geometry 是否保留原内容的几何操作
|
// 入参: page 页面索引, objects 新对象, preserveContent 是否保留原内容
|
||||||
// 返回: error 错误信息
|
// 返回: 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))
|
ids := make([]string, len(objects))
|
||||||
for i, object := range objects {
|
for i, object := range objects {
|
||||||
ids[i] = editorObjectID(object)
|
ids[i] = editorObjectID(object)
|
||||||
@@ -361,11 +361,11 @@ func (e *Editor) updateObjects(page int, objects []GraphicObject, geometry bool)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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())
|
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 {
|
if err := validateEditorGeometry(object); err != nil {
|
||||||
return err
|
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
|
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 {
|
if err := e.prunePageReferences(parts); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
+105
-1
@@ -14,7 +14,10 @@
|
|||||||
|
|
||||||
package ofdgo
|
package ofdgo
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
// editorDrawParam 解析原文档的绘制参数继承链,缺失或循环引用不进入编辑快照
|
// editorDrawParam 解析原文档的绘制参数继承链,缺失或循环引用不进入编辑快照
|
||||||
// 入参: id 绘制参数标识, visited 已访问标识
|
// 入参: id 绘制参数标识, visited 已访问标识
|
||||||
@@ -89,3 +92,104 @@ func (e *Editor) resolveEditorStyle(object GraphicObject, layer string) (Graphic
|
|||||||
}
|
}
|
||||||
return cloneEditorObject(object)
|
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"
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"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 基本图形类型
|
// ShapeKind 基本图形类型
|
||||||
type ShapeKind string
|
type ShapeKind string
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,12 @@ func (e *Editor) writeParts(write func(string, []byte, bool) error) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if name == "Doc_0/Document.xml" {
|
||||||
|
data, err = e.withOutlines(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return write(name, data, false)
|
return write(name, data, false)
|
||||||
}
|
}
|
||||||
if err := writeXML("OFD.xml", func(x *ofdXML) {
|
if err := writeXML("OFD.xml", func(x *ofdXML) {
|
||||||
|
|||||||
Reference in new issue
Block a user