feat(编辑能力): 完善文字与页面编辑,优化预览、保存性能及操作稳定性
build.yaml / build (push) Successful in 37s

This commit is contained in:
xiaoqidun committed 2026-09-17 08:21:10 +08:00
1 parent 5c699023f5
commit cabe631b97
12 files changed
+653 -116

No files matched your search

+3 -1
View File
@@ -166,8 +166,9 @@
</div> </div>
<dialog id="batchPagesPanel" class="form-dialog" aria-label="批量页面"> <dialog id="batchPagesPanel" class="form-dialog" aria-label="批量页面">
<form id="batchPagesForm"> <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="batchPageAction">操作</label><select id="batchPageAction"><option value="copy">复制</option><option value="move">移动</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-row"><label for="batchPageRange">页码</label><input id="batchPageRange" type="text" required autocomplete="off" spellcheck="false" autofocus></div>
<div id="batchPagePositionRow" class="form-row" hidden><label for="batchPagePosition">位置</label><select id="batchPagePosition"><option value="before">页前</option><option value="after">页后</option><option value="first">首部</option><option value="last">末尾</option></select></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> <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> </form>
</dialog> </dialog>
@@ -239,6 +240,7 @@
<div class="form-row"><label for="importRange">范围</label><select id="importRange"><option value="all">全部</option><option value="custom">指定</option></select></div> <div class="form-row"><label for="importRange">范围</label><select id="importRange"><option value="all">全部</option><option value="custom">指定</option></select></div>
<div id="importPagesRow" class="form-row" hidden><label for="importPages">页码</label><input id="importPages" type="text" autocomplete="off" spellcheck="false" disabled></div> <div id="importPagesRow" class="form-row" hidden><label for="importPages">页码</label><input id="importPages" type="text" autocomplete="off" spellcheck="false" disabled></div>
<div class="form-row"><label for="importPosition">位置</label><select id="importPosition"><option value="before">页前</option><option value="after" selected>页后</option><option value="first">首部</option><option value="last">末尾</option></select></div> <div class="form-row"><label for="importPosition">位置</label><select id="importPosition"><option value="before">页前</option><option value="after" selected>页后</option><option value="first">首部</option><option value="last">末尾</option></select></div>
<div class="form-row"><label for="importOutlines">目录</label><input id="importOutlines" type="checkbox" title="导入对应目录"></div>
<div class="form-actions"><div id="importStatus" class="form-status" role="status"></div><button id="importCancel" class="button" type="button">取消</button><button id="importSubmit" class="button" type="submit" disabled>插入</button></div> <div class="form-actions"><div id="importStatus" class="form-status" role="status"></div><button id="importCancel" class="button" type="button">取消</button><button id="importSubmit" class="button" type="submit" disabled>插入</button></div>
</form> </form>
</dialog> </dialog>
+6
View File
@@ -639,6 +639,12 @@ button:not(:disabled):hover {
gap: 12px; gap: 12px;
} }
.form-row input[type="checkbox"] {
justify-self: start;
margin: 0;
accent-color: var(--accent);
}
.form-pair .form-unit { .form-pair .form-unit {
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
gap: 4px; gap: 4px;
+117 -35
View File
@@ -1,4 +1,4 @@
import { CanvasEditor, canEditObject, editTextValue, missingGlyphMessage, objectEditReason, selectedText, lineShape, selectionBounds } from "./ofdgo_edit.js"; import { CanvasEditor, canEditObject, editTextValue, missingGlyphMessage, objectEditReason, selectedText, lineShape, selectionBounds, pagePoint } from "./ofdgo_edit.js";
import { FontManager, FontPicker } from "./ofdgo_font.js"; import { FontManager, FontPicker } from "./ofdgo_font.js";
const MM_TO_PX = 96 / 25.4; const MM_TO_PX = 96 / 25.4;
@@ -143,6 +143,7 @@ const el = {
importPagesRow: document.querySelector("#importPagesRow"), importPagesRow: document.querySelector("#importPagesRow"),
importPages: document.querySelector("#importPages"), importPages: document.querySelector("#importPages"),
importPosition: document.querySelector("#importPosition"), importPosition: document.querySelector("#importPosition"),
importOutlines: document.querySelector("#importOutlines"),
importStatus: document.querySelector("#importStatus"), importStatus: document.querySelector("#importStatus"),
importCancel: document.querySelector("#importCancel"), importCancel: document.querySelector("#importCancel"),
importSubmit: document.querySelector("#importSubmit"), importSubmit: document.querySelector("#importSubmit"),
@@ -174,6 +175,8 @@ const el = {
batchPagesForm: document.querySelector("#batchPagesForm"), batchPagesForm: document.querySelector("#batchPagesForm"),
batchPageAction: document.querySelector("#batchPageAction"), batchPageAction: document.querySelector("#batchPageAction"),
batchPageRange: document.querySelector("#batchPageRange"), batchPageRange: document.querySelector("#batchPageRange"),
batchPagePositionRow: document.querySelector("#batchPagePositionRow"),
batchPagePosition: document.querySelector("#batchPagePosition"),
batchPagesStatus: document.querySelector("#batchPagesStatus"), batchPagesStatus: document.querySelector("#batchPagesStatus"),
batchPagesCancel: document.querySelector("#batchPagesCancel"), batchPagesCancel: document.querySelector("#batchPagesCancel"),
batchPagesSubmit: document.querySelector("#batchPagesSubmit"), batchPagesSubmit: document.querySelector("#batchPagesSubmit"),
@@ -331,7 +334,7 @@ const fontPicker = new FontPicker(el.textFont, el.textFontToggle, el.textFontLis
el.editorTools.addEventListener("scroll", () => fontPicker.position()); el.editorTools.addEventListener("scroll", () => fontPicker.position());
const canvasEditor = new CanvasEditor(el.viewerPanel, { const canvasEditor = new CanvasEditor(el.viewerPanel, {
fontControls: [el.textFont, el.textFontToggle, el.textFontAdd], textControls: [el.textFont, el.textFontToggle, el.textFontAdd, el.textSize, el.textColor],
busy: () => document.body.hasAttribute("aria-busy"), busy: () => document.body.hasAttribute("aria-busy"),
rotation: () => state.rotation, rotation: () => state.rotation,
canInsert: (index) => pageCan("insert", index), canInsert: (index) => pageCan("insert", index),
@@ -345,6 +348,10 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
}, },
onTransform: (item, change) => changeDocument(item.items ? "ofdgoTransformObjects" : "ofdgoTransformObject", item, onTransform: (item, change) => changeDocument(item.items ? "ofdgoTransformObjects" : "ofdgoTransformObject", item,
change.x + item.x * (1 - change.scale), change.y + item.y * (1 - change.scale), change.scale), change.x + item.x * (1 - change.scale), change.y + item.y * (1 - change.scale), change.scale),
onNudgeChange: () => {
updatePendingChanges();
el.undoButton.disabled = !state.ready || state.exporting || !state.editorInfo?.canUndo && !canvasEditor.nudgeChanged();
},
onReshape: (item, box) => changeDocument("ofdgoReshapeObject", item, box.x, box.y, box.width, box.height), onReshape: (item, box) => changeDocument("ofdgoReshapeObject", item, box.x, box.y, box.width, box.height),
onTextWidth: (item, box) => changeDocument("ofdgoLayoutText", item, box.x - item.x, box.width, true, item.align || "", item.paragraphHeight || 0, item.letterSpacing || 0, ...textIndents(item)), onTextWidth: (item, box) => changeDocument("ofdgoLayoutText", item, box.x - item.x, box.width, true, item.align || "", item.paragraphHeight || 0, item.letterSpacing || 0, ...textIndents(item)),
onCrop: (item, box) => changeDocument("ofdgoCropImage", item, box.x, box.y, box.width, box.height), onCrop: (item, box) => changeDocument("ofdgoCropImage", item, box.x, box.y, box.width, box.height),
@@ -369,9 +376,9 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
onDraw: (index, shape, box, style) => changeDocument("ofdgoInsertShape", null, index, shape, onDraw: (index, shape, box, style) => changeDocument("ofdgoInsertShape", null, index, shape,
box.x, box.y, box.width, box.height, style.fill, style.fillColor, style.stroke, style.strokeColor, style.lineWidth), box.x, box.y, box.width, box.height, style.fill, style.fillColor, style.stroke, style.strokeColor, style.lineWidth),
onDrawText: beginCanvasText, onDrawText: beginCanvasText,
onCommitText: (item, value, fontData) => item.draft onCommitText: (item, value, fontData, color = null) => item.draft
? changeDocument("ofdgoInsertText", null, item.index, value, fontData || item.fontData, item.x, item.y, item.size, item.color, item.width, item.wrap, item.align, item.paragraphHeight, item.letterSpacing, ...textIndents(item)) ? changeDocument("ofdgoInsertText", null, item.index, value, fontData || item.fontData, item.x, item.y, item.size, item.color, item.width, item.wrap, item.align, item.paragraphHeight, item.letterSpacing, ...textIndents(item))
: changeDocument("ofdgoUpdateText", item, value, fontData || null, item.size, null), : changeDocument("ofdgoUpdateText", item, value, fontData || null, item.size, color),
onTextChange: () => { onTextChange: () => {
const editing = canvasEditor.input; const editing = canvasEditor.input;
updatePendingChanges(); updatePendingChanges();
@@ -384,17 +391,17 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
}); });
function updatePendingChanges() { function updatePendingChanges() {
const editing = canvasEditor.input;
setDirty(Boolean(state.editorInfo) && (state.editorInfo.revision !== state.savedRevision setDirty(Boolean(state.editorInfo) && (state.editorInfo.revision !== state.savedRevision
|| Boolean(editing && (editTextValue(editing.input) !== editing.item.text || editing.fontData)) || canvasEditor.cropChanged())); || canvasEditor.textChanged() || canvasEditor.cropChanged() || canvasEditor.nudgeChanged()));
} }
el.editorTools.addEventListener("pointerdown", (event) => { el.editorTools.addEventListener("pointerdown", (event) => {
if (canvasEditor.input && event.target.closest("button")) event.preventDefault(); if ((canvasEditor.input || canvasEditor.nudge) && event.target.closest("button")) event.preventDefault();
}); });
function editorClick(button, action) { function editorClick(button, action) {
button.addEventListener("click", async () => { button.addEventListener("click", async () => {
if ((canvasEditor.nudge || canvasEditor.nudgeCommit) && !await canvasEditor.commitNudge()) return;
if (canvasEditor.input && !await canvasEditor.commitText()) return; if (canvasEditor.input && !await canvasEditor.commitText()) return;
if (canvasEditor.crop && !await canvasEditor.commitCrop()) return; if (canvasEditor.crop && !await canvasEditor.commitCrop()) return;
return action(); return action();
@@ -582,7 +589,7 @@ el.importForm.addEventListener("submit", async event => {
event.preventDefault(); event.preventDefault();
if (document.body.hasAttribute("aria-busy") || !state.importPageCount || !el.importForm.reportValidity()) return; if (document.body.hasAttribute("aria-busy") || !state.importPageCount || !el.importForm.reportValidity()) return;
const position = { before:state.pageIndex, after:state.pageIndex+1, first:0, last:state.doc.pageCount }[el.importPosition.value]; const position = { before:state.pageIndex, after:state.pageIndex+1, first:0, last:state.doc.pageCount }[el.importPosition.value];
const saved = await changeDocument("ofdgoImportPages", null, el.importRange.value === "custom" ? el.importPages.value : "", position); const saved = await changeDocument("ofdgoImportPages", null, el.importRange.value === "custom" ? el.importPages.value : "", position, el.importOutlines.checked);
if (saved) el.importPanel.close(); if (saved) el.importPanel.close();
}); });
editorClick(el.copyPageButton, () => changeDocument("ofdgoChangePage", null, "copy", state.pageIndex)); editorClick(el.copyPageButton, () => changeDocument("ofdgoChangePage", null, "copy", state.pageIndex));
@@ -593,6 +600,8 @@ editorClick(el.pageSettingsButton, openPagePanel);
editorClick(el.batchPagesButton, () => { editorClick(el.batchPagesButton, () => {
el.batchPageAction.value = "copy"; el.batchPageAction.value = "copy";
el.batchPagesSubmit.textContent = "复制"; el.batchPagesSubmit.textContent = "复制";
el.batchPagePositionRow.hidden = true;
el.batchPagePosition.value = "after";
el.batchPageRange.value = String(state.pageIndex + 1); el.batchPageRange.value = String(state.pageIndex + 1);
el.batchPagesStatus.textContent = ""; el.batchPagesStatus.textContent = "";
el.batchPagesPanel.showModal(); el.batchPagesPanel.showModal();
@@ -600,11 +609,14 @@ editorClick(el.batchPagesButton, () => {
}); });
el.batchPagesCancel.addEventListener("click", () => el.batchPagesPanel.close()); el.batchPagesCancel.addEventListener("click", () => el.batchPagesPanel.close());
el.batchPageAction.addEventListener("change", () => { el.batchPageAction.addEventListener("change", () => {
el.batchPagesSubmit.textContent = el.batchPageAction.value === "copy" ? "复制" : "删除"; el.batchPagesSubmit.textContent = { copy: "复制", move: "移动", delete: "删除" }[el.batchPageAction.value];
el.batchPagePositionRow.hidden = el.batchPageAction.value !== "move";
}); });
el.batchPagesForm.addEventListener("submit", async event => { el.batchPagesForm.addEventListener("submit", async event => {
event.preventDefault(); event.preventDefault();
if (await changeDocument("ofdgoBatchPages", null, el.batchPageAction.value, el.batchPageRange.value)) el.batchPagesPanel.close(); const args = [el.batchPageAction.value, el.batchPageRange.value];
if (args[0] === "move") args.push({ before: state.pageIndex, after: state.pageIndex + 1, first: 0, last: state.doc.pageCount }[el.batchPagePosition.value]);
if (await changeDocument("ofdgoBatchPages", null, ...args)) el.batchPagesPanel.close();
}); });
editorClick(el.objectStyleButton, openObjectStyle); editorClick(el.objectStyleButton, openObjectStyle);
editorClick(el.objectBoundsButton, openObjectBounds); editorClick(el.objectBoundsButton, openObjectBounds);
@@ -751,6 +763,7 @@ el.panButton.addEventListener("click", togglePan);
el.continuousButton.addEventListener("click", toggleContinuous); el.continuousButton.addEventListener("click", toggleContinuous);
el.annotationButton.addEventListener("click", toggleAnnotations); el.annotationButton.addEventListener("click", toggleAnnotations);
document.addEventListener("copy", copySelection); document.addEventListener("copy", copySelection);
document.addEventListener("cut", copySelection);
el.viewerPanel.addEventListener("paste", pasteEditorContent); el.viewerPanel.addEventListener("paste", pasteEditorContent);
document.addEventListener("selectionchange", syncSelection); document.addEventListener("selectionchange", syncSelection);
el.exportFormat.addEventListener("change", () => updateDPIControl()); el.exportFormat.addEventListener("change", () => updateDPIControl());
@@ -1141,7 +1154,8 @@ function setEditorInfo(doc) {
} }
function pageCan(capability, index = state.pageIndex) { function pageCan(capability, index = state.pageIndex) {
return Boolean(state.editorInfo?.pageCapabilities?.[index]?.[capability]); const bit = { insert: 1, copy: 2, delete: 4, move: 8, resize: 16 }[capability];
return Boolean(state.editorInfo?.pageCapabilities?.[index] & bit);
} }
async function toggleEditor() { async function toggleEditor() {
@@ -1327,7 +1341,8 @@ function openInsertPanel(item = null) {
} }
function currentTextStyle() { function currentTextStyle() {
return selectedText(canvasEditor.selected) || state.textDefaults; const text = selectedText(canvasEditor.selected) || state.textDefaults;
return canvasEditor.input?.style ? { ...text, ...canvasEditor.input.style } : text;
} }
function textIndents(item) { function textIndents(item) {
@@ -1349,6 +1364,7 @@ async function toggleTextTool() {
const style = !canvasEditor.selected?.items && canEditObject(canvasEditor.selected, "layoutKnown") ? currentTextStyle() : state.textDefaults; const style = !canvasEditor.selected?.items && canEditObject(canvasEditor.selected, "layoutKnown") ? currentTextStyle() : state.textDefaults;
const font = state.textFonts[Number(fontPicker.value)]; const font = state.textFonts[Number(fontPicker.value)];
state.textDefaults = { type: "TextObject", size: style.size, color: style.color, wrap: style.wrap, state.textDefaults = { type: "TextObject", size: style.size, color: style.color, wrap: style.wrap,
wrapOverride: style === state.textDefaults ? style.wrapOverride : style.wrap,
align: style.align || "left", paragraphHeight: style.paragraphHeight || 0, letterSpacing: style.letterSpacing || 0, align: style.align || "left", paragraphHeight: style.paragraphHeight || 0, letterSpacing: style.letterSpacing || 0,
leftIndent: style.leftIndent || 0, rightIndent: style.rightIndent || 0, firstLineIndent: style.firstLineIndent || 0, leftIndent: style.leftIndent || 0, rightIndent: style.rightIndent || 0, firstLineIndent: style.firstLineIndent || 0,
fontChoice: font && !font.disabled ? font : state.textDefaults.fontChoice }; fontChoice: font && !font.disabled ? font : state.textDefaults.fontChoice };
@@ -1373,7 +1389,8 @@ async function beginCanvasText(index, box, surface, page) {
await face.load(); await face.load();
if (openSeq !== state.openSeq || !surface.isConnected) return; if (openSeq !== state.openSeq || !surface.isConnected) return;
const node = document.createElement("div"); const node = document.createElement("div");
const item = { ...state.textDefaults, ...box, index, page, surface, node, text: "", draft: true, fontData: data }; const item = { ...state.textDefaults, ...box, wrap: state.textDefaults.wrapOverride ?? box.wrap ?? state.textDefaults.wrap,
index, page, surface, node, text: "", draft: true, fontData: data };
canvasEditor.editText(item, face); canvasEditor.editText(item, face);
} catch (err) { } catch (err) {
if (openSeq === state.openSeq) showError(err, false); if (openSeq === state.openSeq) showError(err, false);
@@ -1548,8 +1565,10 @@ function inputMillimeters(input, original) {
return points === displayPoints(original) ? original : points * 25.4 / 72; return points === displayPoints(original) ? original : points * 25.4 / 72;
} }
async function changeParagraph(wrap = currentTextStyle().wrap) { async function changeParagraph(wrap) {
const item = currentTextStyle(); const item = currentTextStyle();
const explicitWrap = wrap !== undefined;
wrap ??= item.wrap;
if (document.body.hasAttribute("aria-busy") || item.draft) return; if (document.body.hasAttribute("aria-busy") || item.draft) return;
if (!el.textLineHeight.checkValidity()) { if (!el.textLineHeight.checkValidity()) {
updateObjectControls(item, true); updateObjectControls(item, true);
@@ -1563,6 +1582,7 @@ async function changeParagraph(wrap = currentTextStyle().wrap) {
const spacing = inputMillimeters(el.textSpacing, item.letterSpacing || 0); const spacing = inputMillimeters(el.textSpacing, item.letterSpacing || 0);
if (item === state.textDefaults) { if (item === state.textDefaults) {
Object.assign(item, { wrap: Boolean(wrap), align: el.textAlign.value, paragraphHeight: height, letterSpacing: spacing }); Object.assign(item, { wrap: Boolean(wrap), align: el.textAlign.value, paragraphHeight: height, letterSpacing: spacing });
if (explicitWrap) item.wrapOverride = Boolean(wrap);
updateObjectControls(canvasEditor.selected, true); updateObjectControls(canvasEditor.selected, true);
return; return;
} }
@@ -1576,7 +1596,7 @@ async function changeParagraph(wrap = currentTextStyle().wrap) {
async function changeTextStyle(color) { async function changeTextStyle(color) {
const item = currentTextStyle(); const item = currentTextStyle();
if (!state.ready || state.exporting || item.draft || document.body.hasAttribute("aria-busy")) { if (!state.ready || state.exporting || document.body.hasAttribute("aria-busy")) {
return; return;
} }
if (!color && !el.textSize.checkValidity()) { if (!color && !el.textSize.checkValidity()) {
@@ -1586,6 +1606,11 @@ async function changeTextStyle(color) {
} }
const size = color ? item.size : inputMillimeters(el.textSize, item.size); const size = color ? item.size : inputMillimeters(el.textSize, item.size);
const fill = color && el.textColor.value !== item.color ? el.textColor.value : null; const fill = color && el.textColor.value !== item.color ? el.textColor.value : null;
if (canvasEditor.input) {
canvasEditor.setTextStyle(color ? { color: el.textColor.value } : { size });
updateObjectControls(canvasEditor.selected, true);
return;
}
if (item.items) { if (item.items) {
if (color ? !canEditObject(item, "update") : !canEditObject(item, "layoutKnown") || !canEditObject(item, "reflow")) return; if (color ? !canEditObject(item, "update") : !canEditObject(item, "layoutKnown") || !canEditObject(item, "reflow")) return;
if (!color && !el.textSize.value) return; if (!color && !el.textSize.value) return;
@@ -2156,7 +2181,10 @@ async function loadWASM() {
el.cancelExportButton.disabled = true; el.cancelExportButton.disabled = true;
setProgress("正在保存", null); setProgress("正在保存", null);
} else if (!el.cancelExportButton.disabled) { } else if (!el.cancelExportButton.disabled) {
if (data.completed === data.total) { if (data.stage === "prepare") {
const label = { fonts: "正在处理字体", pages: "正在处理页面", references: "正在检查引用", resources: "正在整理资源", write: "正在写入" }[data.phase];
setProgress(label, data.total ? data.completed / data.total * 100 : null);
} else if (data.completed === data.total) {
setProgress("正在收尾", null); setProgress("正在收尾", null);
} else { } else {
setProgress(`正在导出 ${data.completed} / ${data.total} 页`, data.completed / data.total * 100); setProgress(`正在导出 ${data.completed} / ${data.total} 页`, data.completed / data.total * 100);
@@ -2961,8 +2989,9 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value)
if (openSeq !== state.openSeq) { if (openSeq !== state.openSeq) {
return; return;
} }
if (canvasEditor.input || canvasEditor.crop) { if (canvasEditor.input || canvasEditor.crop || canvasEditor.nudge || canvasEditor.nudgeCommit) {
setBusy(false); setBusy(false);
if (!await canvasEditor.commitNudge()) return;
if (!await canvasEditor.commitText()) return; if (!await canvasEditor.commitText()) return;
if (!await canvasEditor.commitCrop()) return; if (!await canvasEditor.commitCrop()) return;
openSeq = state.openSeq; openSeq = state.openSeq;
@@ -3455,29 +3484,59 @@ function copyEditorSelection(event) {
if (!state.editing || !canvasEditor.enabled || !canvasEditor.selected || !el.viewerPanel.contains(event.target) if (!state.editing || !canvasEditor.enabled || !canvasEditor.selected || !el.viewerPanel.contains(event.target)
|| canvasEditor.input || canvasEditor.crop || canvasEditor.drag || document.body.hasAttribute("aria-busy")) return false; || canvasEditor.input || canvasEditor.crop || canvasEditor.drag || document.body.hasAttribute("aria-busy")) return false;
const items = canvasEditor.items().slice().sort((a, b) => a.order - b.order); const items = canvasEditor.items().slice().sort((a, b) => a.order - b.order);
if (!canEditObject(canvasEditor.selected, "copy")) { const cut = event.type === "cut";
if (!canEditObject(canvasEditor.selected, "copy") || cut && !canEditObject(canvasEditor.selected, "delete")) {
event.preventDefault(); event.preventDefault();
setStatus("对象不可复制"); setStatus(cut ? "对象不可剪切" : "对象不可复制");
return true; return true;
} }
const clipboard = { token: crypto.randomUUID(), page: state.doc.pages[items[0].index].id, offset: 0 }; const index = items[0].index, ids = items.map(item => item.id);
const clipboard = { token: crypto.randomUUID(), page: state.doc.pages[index].id, bounds: selectionBounds(items), x: 0, y: 0, cut };
if (canvasEditor.nudge) {
clipboard.bounds.x += canvasEditor.nudge.x;
clipboard.bounds.y += canvasEditor.nudge.y;
}
event.clipboardData.setData(OBJECT_CLIPBOARD_TYPE, clipboard.token); event.clipboardData.setData(OBJECT_CLIPBOARD_TYPE, clipboard.token);
event.clipboardData.setData("text/plain", items.map(item => item.text || "").filter(Boolean).join("\n")); event.clipboardData.setData("text/plain", items.map(item => item.text || "").filter(Boolean).join("\n"));
event.preventDefault(); event.preventDefault();
state.objectClipboard = clipboard; state.objectClipboard = clipboard;
clipboard.ready = callWASM("ofdgoCaptureObjects", items[0].index, items.map(item => item.id), clipboard.token) const capture = () => {
.then(() => true, err => { const openSeq = state.openSeq, revision = state.editorInfo.revision;
return callWASM("ofdgoCaptureObjects", index, ids, clipboard.token).then(async () => {
clipboard.cut = false;
if (cut && openSeq === state.openSeq && revision === state.editorInfo?.revision && index === state.pageIndex && state.objectClipboard === clipboard) {
clipboard.cut = Boolean(await changeDocument("ofdgoDeleteObjects", { index, id: ids }));
}
return true;
}, err => {
if (state.objectClipboard === clipboard) showError(err, false); if (state.objectClipboard === clipboard) showError(err, false);
return false; return false;
}); });
};
clipboard.ready = canvasEditor.nudge ? canvasEditor.commitNudge().then(saved => saved ? capture() : false) : capture();
return true; return true;
} }
function visiblePageBounds(index) {
const page = state.doc.pages[index], surface = pageShell(index)?.querySelector(".page-surface");
const full = { x: 0, y: 0, width: page.width, height: page.height };
if (!surface) return full;
const rect = surface.getBoundingClientRect(), view = el.viewerPanel.getBoundingClientRect();
const left = Math.max(rect.left, view.left), top = Math.max(rect.top, view.top);
const right = Math.min(rect.right, view.left + el.viewerPanel.clientWidth), bottom = Math.min(rect.bottom, view.top + el.viewerPanel.clientHeight);
if (right <= left || bottom <= top) return full;
const a = pagePoint(left, top, rect, page, state.rotation), b = pagePoint(right, bottom, rect, page, state.rotation);
return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), width: Math.abs(a.x - b.x), height: Math.abs(a.y - b.y) };
}
async function pasteEditorContent(event) { async function pasteEditorContent(event) {
if (!state.editing || event.target.closest?.("input, textarea, select, [contenteditable]") || formDialogOpen()) return; if (!state.editing || event.target.closest?.("input, textarea, select, [contenteditable]") || formDialogOpen()) return;
event.preventDefault(); event.preventDefault();
if (document.body.hasAttribute("aria-busy") || canvasEditor.input || canvasEditor.crop || canvasEditor.drag || !pageCan("insert")) return;
const token = event.clipboardData.getData(OBJECT_CLIPBOARD_TYPE); const token = event.clipboardData.getData(OBJECT_CLIPBOARD_TYPE);
const files = Array.from(event.clipboardData.files);
const value = event.clipboardData.getData("text/plain").replace(/\t/g, " ");
if ((canvasEditor.nudge || canvasEditor.nudgeCommit) && !await canvasEditor.commitNudge()) return;
if (document.body.hasAttribute("aria-busy") || canvasEditor.input || canvasEditor.crop || canvasEditor.drag || !pageCan("insert")) return;
const index = state.pageIndex, page = state.doc.pages[index]; const index = state.pageIndex, page = state.doc.pages[index];
if (token) { if (token) {
const clipboard = state.objectClipboard; const clipboard = state.objectClipboard;
@@ -3487,15 +3546,25 @@ async function pasteEditorContent(event) {
} }
const openSeq = state.openSeq; const openSeq = state.openSeq;
if (!await clipboard.ready || openSeq !== state.openSeq || index !== state.pageIndex || state.objectClipboard !== clipboard) return; if (!await clipboard.ready || openSeq !== state.openSeq || index !== state.pageIndex || state.objectClipboard !== clipboard) return;
const offset = clipboard.page === page.id ? clipboard.offset + 3 : 0; const visible = visiblePageBounds(index), bounds = clipboard.bounds;
if (await changeDocument("ofdgoPasteObjects", null, index, token, offset, offset)) { const offset = clipboard.page === page.id ? { x: clipboard.x + (clipboard.cut ? 0 : 3), y: clipboard.y + (clipboard.cut ? 0 : 3) } : { x: 0, y: 0 };
for (const [axis, size] of [["x", "width"], ["y", "height"]]) {
const position = bounds[axis] + offset[axis];
if (position < visible[axis] || position + bounds[size] > visible[axis] + visible[size]) {
const margin = Math.min(20, visible[size] / 10);
offset[axis] = Math.max(visible[axis] + margin, Math.min(position, visible[axis] + visible[size] - margin - bounds[size])) - bounds[axis];
}
}
if (await changeDocument("ofdgoPasteObjects", null, index, token, offset.x, offset.y)) {
clipboard.page = page.id; clipboard.page = page.id;
clipboard.offset = offset; clipboard.x = offset.x;
clipboard.y = offset.y;
clipboard.cut = false;
} }
return; return;
} }
const files = Array.from(event.clipboardData.files); const visible = visiblePageBounds(index), margin = Math.min(20, visible.width / 10);
const x = Math.min(20, page.width / 10), y = Math.min(20, page.height / 10); const x = visible.x + margin, y = visible.y + Math.min(20, visible.height / 10), width = visible.width - margin * 2;
if (files.length) { if (files.length) {
if (files.length !== 1) { if (files.length !== 1) {
setStatus("一次粘贴一张图片"); setStatus("一次粘贴一张图片");
@@ -3505,10 +3574,9 @@ async function pasteEditorContent(event) {
setStatus("图片仅支持 PNG、JPG"); setStatus("图片仅支持 PNG、JPG");
return; return;
} }
await changeDocument("ofdgoInsertImage", null, index, files[0].arrayBuffer().then(bytes => new Uint8Array(bytes)), x, y, page.width - x * 2); await changeDocument("ofdgoInsertImage", null, index, files[0].arrayBuffer().then(bytes => new Uint8Array(bytes)), x, y, width);
return; return;
} }
const value = event.clipboardData.getData("text/plain").replace(/\t/g, " ");
if (!value.trim()) return; if (!value.trim()) return;
const font = state.textFonts[Number(fontPicker.value)]; const font = state.textFonts[Number(fontPicker.value)];
if (!font || font.disabled) { if (!font || font.disabled) {
@@ -3519,7 +3587,7 @@ async function pasteEditorContent(event) {
const style = canvasEditor.selected?.items ? state.textDefaults : currentTextStyle(); const style = canvasEditor.selected?.items ? state.textDefaults : currentTextStyle();
const data = readTextFont(font); const data = readTextFont(font);
await changeDocument("ofdgoInsertText", null, index, value, data, x, y, style.size, style.color, await changeDocument("ofdgoInsertText", null, index, value, data, x, y, style.size, style.color,
page.width - x * 2, style.wrap, style.align || "left", style.paragraphHeight || 0, style.letterSpacing || 0, ...textIndents(style)); width, style.wrap, style.align || "left", style.paragraphHeight || 0, style.letterSpacing || 0, ...textIndents(style));
} }
function copySelection(event) { function copySelection(event) {
@@ -4046,6 +4114,18 @@ function createOutlineList(outlines, path = []) {
} }
if (outline.page || state.editing) { if (outline.page || state.editing) {
link.type = "button"; link.type = "button";
link.addEventListener("keydown", async event => {
if (!state.editing || event.defaultPrevented || event.isComposing || !event.altKey || event.ctrlKey || event.metaKey || event.shiftKey
|| !["ArrowUp", "ArrowDown"].includes(event.key)) return;
event.preventDefault();
const target = index + (event.key === "ArrowUp" ? -1 : 1);
if (event.repeat || document.body.hasAttribute("aria-busy") || target < 0 || target >= outlines.length) return;
if (await changeDocument("ofdgoMoveOutline", null, current, path, target)) {
const selected = el.outlineList.querySelector(".outline-link.selected");
selected?.focus({ preventScroll: true });
selected?.scrollIntoView({ block: "nearest" });
}
});
link.addEventListener("click", (event) => { link.addEventListener("click", (event) => {
event.preventDefault(); event.preventDefault();
if (state.editing) { if (state.editing) {
@@ -4936,6 +5016,8 @@ function applyFit(updateStatus = true) {
} }
if (state.fitMode === "width") { if (state.fitMode === "width") {
fitWidth(updateStatus); fitWidth(updateStatus);
} else {
updateControls();
} }
} }
@@ -5108,13 +5190,13 @@ function updateObjectControls(item, reset = false) {
|| !canEditObject(item, item.type === "TextObject" ? "reflow" : "update"); || !canEditObject(item, item.type === "TextObject" ? "reflow" : "update");
el.multiSelectButton.disabled = !state.editing || !canvasEditor.enabled || !state.ready || state.exporting; el.multiSelectButton.disabled = !state.editing || !canvasEditor.enabled || !state.ready || state.exporting;
const selection = selectedText(item); const selection = selectedText(item);
const text = selection || state.textDefaults; const text = selection ? currentTextStyle() : state.textDefaults;
const textDisabled = !state.editing || !state.ready || state.exporting || Boolean(item?.items && !selection); const textDisabled = !state.editing || !state.ready || state.exporting || Boolean(item?.items && !selection);
fontPicker.setDisabled(textDisabled || text !== state.textDefaults && !item.draft && !canEditObject(item, "replaceFont")); fontPicker.setDisabled(textDisabled || text !== state.textDefaults && !item.draft && !canEditObject(item, "replaceFont"));
el.textSize.disabled = textDisabled || Boolean(item?.draft) || text !== state.textDefaults && (!canEditObject(item, "reflow") || Boolean(item?.items) && !canEditObject(item, "layoutKnown")); el.textSize.disabled = textDisabled || text !== state.textDefaults && !item.draft && (!canEditObject(item, "reflow") || Boolean(item?.items) && !canEditObject(item, "layoutKnown"));
el.textColor.disabled = textDisabled || Boolean(item?.draft) || text !== state.textDefaults && !canEditObject(item, "update"); el.textColor.disabled = textDisabled || text !== state.textDefaults && !item.draft && !canEditObject(item, "update");
el.textFontAdd.disabled = !state.editing || !state.ready || state.exporting; el.textFontAdd.disabled = !state.editing || !state.ready || state.exporting;
el.textAlign.disabled = el.textWrap.disabled = el.textLineHeight.disabled = el.textSpacing.disabled = el.textSize.disabled || Boolean(item?.items); el.textAlign.disabled = el.textWrap.disabled = el.textLineHeight.disabled = el.textSpacing.disabled = el.textSize.disabled || Boolean(item?.items || item?.draft) || Boolean(canvasEditor.input);
el.paragraphButton.disabled = el.textAlign.disabled; el.paragraphButton.disabled = el.textAlign.disabled;
const layoutKnown = text === state.textDefaults || Boolean(item?.draft) || canEditObject(item, "layoutKnown"); const layoutKnown = text === state.textDefaults || Boolean(item?.draft) || canEditObject(item, "layoutKnown");
el.textAlign.value = layoutKnown ? text.align || "left" : ""; el.textAlign.value = layoutKnown ? text.align || "left" : "";
+139 -17
View File
@@ -222,15 +222,16 @@ export class CanvasEditor {
viewer.addEventListener("pointercancel", () => this.cancel()); viewer.addEventListener("pointercancel", () => this.cancel());
viewer.addEventListener("lostpointercapture", () => this.cancel()); viewer.addEventListener("lostpointercapture", () => this.cancel());
viewer.addEventListener("keydown", (event) => this.keyDown(event)); viewer.addEventListener("keydown", (event) => this.keyDown(event));
viewer.addEventListener("keyup", (event) => this.modifierChange(event)); viewer.addEventListener("keyup", (event) => this.keyUp(event));
viewer.addEventListener("blur", () => this.commitNudge());
viewer.addEventListener("dblclick", (event) => { viewer.addEventListener("dblclick", (event) => {
const item = this.selected; const item = this.selected;
if (!event.altKey && this.enabled && item && !item.items && !this.input && !this.options.busy()) { if (!event.altKey && this.enabled && item && !item.items && !this.input && !this.options.busy()) {
this.options.onEdit(item); this.options.onEdit(item);
} }
}); });
viewer.addEventListener("scroll", () => this.cancel()); viewer.addEventListener("scroll", () => this.scroll());
window.addEventListener("blur", () => this.cancel()); window.addEventListener("blur", () => { this.cancel(); this.commitNudge(); });
window.addEventListener("resize", () => this.cancel()); window.addEventListener("resize", () => this.cancel());
} }
@@ -387,6 +388,7 @@ export class CanvasEditor {
} }
setSelection(items) { setSelection(items) {
this.cancelNudge();
const previous = this.selected; const previous = this.selected;
if (this.crop && (items.length !== 1 || items[0] !== this.crop.item)) this.closeCrop(); if (this.crop && (items.length !== 1 || items[0] !== this.crop.item)) this.closeCrop();
if (this.selected?.items) this.selected.node.remove(); if (this.selected?.items) this.selected.node.remove();
@@ -436,11 +438,15 @@ export class CanvasEditor {
} }
start(event) { start(event) {
if (this.nudge) {
this.commitNudge();
return;
}
if (this.input) { if (this.input) {
if (!this.input.input.contains(event.target)) this.commitText(); if (!this.input.input.contains(event.target)) this.commitText();
return; return;
} }
if (!this.enabled || this.options.busy() || event.button !== 0 || this.drag) { if (!this.enabled || this.options.busy() || this.nudgeCommit || event.button !== 0 || this.drag) {
return; return;
} }
if (this.crop) { if (this.crop) {
@@ -562,7 +568,7 @@ export class CanvasEditor {
if (!drag.change && !drag.box && Math.hypot(event.clientX - drag.clientX, event.clientY - drag.clientY) < 3) { if (!drag.change && !drag.box && Math.hypot(event.clientX - drag.clientX, event.clientY - drag.clientY) < 3) {
return; return;
} }
const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.item.page, drag.rotation); const from = drag.origin ||= pagePoint(drag.clientX, drag.clientY, drag.rect, drag.item.page, drag.rotation);
const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.item.page, drag.rotation); const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.item.page, drag.rotation);
if (drag.item.type === "TextObject" && (drag.corner === "w" || drag.corner === "e")) { if (drag.item.type === "TextObject" && (drag.corner === "w" || drag.corner === "e")) {
if (drag.item.textFrame) { if (drag.item.textFrame) {
@@ -587,15 +593,50 @@ export class CanvasEditor {
} }
const transform = drag.item.type === "TextObject" ? textTransform : objectTransform; const transform = drag.item.type === "TextObject" ? textTransform : objectTransform;
drag.change = transform(drag.item, to.x - from.x, to.y - from.y, drag.corner); drag.change = transform(drag.item, to.x - from.x, to.y - from.y, drag.corner);
if (!drag.corner) this.snap(drag, event.altKey); if (!drag.corner) {
drag.axis = event.shiftKey ? Math.abs(drag.change.x) >= Math.abs(drag.change.y) ? "x" : "y" : "";
if (drag.axis) drag.change[drag.axis === "x" ? "y" : "x"] = 0;
this.snap(drag, event.altKey);
this.autoScroll(drag);
}
this.place(drag.item, drag.change); this.place(drag.item, drag.change);
} }
scroll() {
const drag = this.drag;
if (!drag?.item || drag.corner || !drag.pointer) {
this.cancel();
return;
}
drag.origin ||= pagePoint(drag.clientX, drag.clientY, drag.rect, drag.item.page, drag.rotation);
drag.rect = drag.item.surface.getBoundingClientRect();
this.move(drag.pointer);
}
autoScroll(drag) {
if (drag.scrolling) return;
drag.scrolling = true;
requestAnimationFrame(time => {
drag.scrolling = false;
if (this.drag !== drag) return;
const viewer = this.viewer, rect = viewer.getBoundingClientRect(), pointer = drag.pointer;
const speed = (value, start, size) => value < start + 32 ? Math.max(-1, (value - start - 32) / 32)
: value > start + size - 32 ? Math.min(1, (value - start - size + 32) / 32) : 0;
const elapsed = Math.min(32, time - (drag.scrollTime ?? time - 16));
drag.scrollTime = time;
const left = viewer.scrollLeft, top = viewer.scrollTop;
viewer.scrollLeft += speed(pointer.clientX, rect.left, viewer.clientWidth) * elapsed;
viewer.scrollTop += speed(pointer.clientY, rect.top, viewer.clientHeight) * elapsed;
if (viewer.scrollLeft !== left || viewer.scrollTop !== top) this.scroll();
else drag.scrollTime = null;
});
}
snap(drag, bypass = false) { snap(drag, bypass = false) {
const { item, rect, rotation, change } = drag; const { item, rect, rotation, change } = drag;
const { x, y, path } = bypass ? { x: 0, y: 0, path: "" } : alignmentSnap({ ...item, x: item.x + change.x, y: item.y + change.y }, drag.targets, { const { x, y, path } = bypass ? { x: 0, y: 0, path: "" } : alignmentSnap({ ...item, x: item.x + change.x, y: item.y + change.y }, drag.targets, {
x: 4 * item.page.width / (rotation % 180 ? rect.height : rect.width), x: drag.axis === "y" ? 0 : 4 * item.page.width / (rotation % 180 ? rect.height : rect.width),
y: 4 * item.page.height / (rotation % 180 ? rect.width : rect.height), y: drag.axis === "x" ? 0 : 4 * item.page.height / (rotation % 180 ? rect.width : rect.height),
}); });
change.x += x; change.x += x;
change.y += y; change.y += y;
@@ -843,6 +884,38 @@ export class CanvasEditor {
} }
} }
keyUp(event) {
this.modifierChange(event);
if (this.nudge?.keys.delete(event.key) && !this.nudge.keys.size) return this.commitNudge();
}
nudgeChanged() {
return Boolean(this.nudge && (this.nudge.x || this.nudge.y));
}
cancelNudge() {
const nudge = this.nudge;
this.nudge = null;
if (nudge) {
this.place(nudge.item);
this.options.onNudgeChange?.();
}
}
commitNudge() {
if (this.nudgeCommit) return this.nudgeCommit;
const nudge = this.nudge;
if (!nudge) return Promise.resolve(true);
this.nudge = null;
this.nudgeCommit = Promise.resolve().then(() => nudge.x || nudge.y
? this.options.onTransform(nudge.item, { x: nudge.x, y: nudge.y, scale: 1 }) : true).finally(() => {
this.place(nudge.item);
this.nudgeCommit = null;
this.options.onNudgeChange?.();
});
return this.nudgeCommit;
}
keyDown(event) { keyDown(event) {
if (event.defaultPrevented || event.target?.closest("input, textarea, select, [contenteditable]")) return; if (event.defaultPrevented || event.target?.closest("input, textarea, select, [contenteditable]")) return;
if (this.crop && !this.options.busy()) { if (this.crop && !this.options.busy()) {
@@ -868,7 +941,7 @@ export class CanvasEditor {
this.setTool(""); this.setTool("");
return; return;
} }
if (this.input || !this.enabled || !this.selected || this.options.busy() || event.ctrlKey || event.metaKey || event.altKey || event.isComposing) { if (this.input || !this.enabled || !this.selected || this.options.busy() || this.nudgeCommit || event.ctrlKey || event.metaKey || event.altKey || event.isComposing) {
return; return;
} }
const directions = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }; const directions = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
@@ -879,16 +952,23 @@ export class CanvasEditor {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (event.key === "Escape") { if (event.key === "Escape") {
this.drag ? this.cancel() : this.clear(); this.nudge ? this.cancelNudge() : this.drag ? this.cancel() : this.clear();
} else if (event.key === "Delete" && canEditObject(this.selected, "delete")) { } else if (event.key === "Delete" && canEditObject(this.selected, "delete")) {
this.options.onDelete(this.selected); if (this.nudge) this.commitNudge().then(saved => { if (saved && this.selected) this.options.onDelete(this.selected); });
else this.options.onDelete(this.selected);
} else if (edit && !this.drag && !this.selected.items) { } else if (edit && !this.drag && !this.selected.items) {
this.options.onEdit(this.selected); if (this.nudge) this.commitNudge().then(saved => { if (saved && this.selected) this.options.onEdit(this.selected); });
else this.options.onEdit(this.selected);
} else if (!this.drag && directions[event.key] && canEditObject(this.selected, "transform")) { } else if (!this.drag && directions[event.key] && canEditObject(this.selected, "transform")) {
const [x, y] = directions[event.key]; const [x, y] = directions[event.key];
const [dx, dy] = [[x, y], [y, -x], [-x, -y], [-y, x]][this.options.rotation() / 90]; const [dx, dy] = [[x, y], [y, -x], [-x, -y], [-y, x]][this.options.rotation() / 90];
const step = event.shiftKey ? 10 : 1; const step = event.shiftKey ? 10 : 1;
this.options.onTransform(this.selected, { x: dx * step, y: dy * step, scale: 1 }); const nudge = this.nudge ||= { item: this.selected, x: 0, y: 0, keys: new Set() };
nudge.keys.add(event.key);
nudge.x += dx * step;
nudge.y += dy * step;
this.place(nudge.item, { x: nudge.x, y: nudge.y, scale: 1 });
this.options.onNudgeChange?.();
} }
} }
@@ -926,8 +1006,16 @@ export class CanvasEditor {
textIndent: `${(item.firstLineIndent || 0) * PX_PER_MM}px`, textIndent: `${(item.firstLineIndent || 0) * PX_PER_MM}px`,
}); });
this.input = { input, item, face, fontChoice: item.fontChoice }; this.input = { input, item, face, fontChoice: item.fontChoice };
const editing = this.input;
item.surface.append(input); item.surface.append(input);
input.addEventListener("input", () => this.options.onTextChange()); input.addEventListener("input", () => this.options.onTextChange());
input.addEventListener("compositionstart", () => { editing.composing = true; });
input.addEventListener("compositionend", () => {
editing.composing = false;
Promise.resolve().then(() => {
if (this.input === editing && editing.commitAfterComposition) this.commitText();
});
});
input.addEventListener("paste", event => { input.addEventListener("paste", event => {
event.preventDefault(); event.preventDefault();
document.execCommand("insertText", false, event.clipboardData.getData("text/plain")); document.execCommand("insertText", false, event.clipboardData.getData("text/plain"));
@@ -940,12 +1028,22 @@ export class CanvasEditor {
} }
}); });
input.addEventListener("blur", (event) => { input.addEventListener("blur", (event) => {
if (!this.options.fontControls?.includes(event.relatedTarget)) this.commitText(); const selection = document.getSelection();
if (selection?.rangeCount && input.contains(selection.anchorNode)) editing.range = selection.getRangeAt(0).cloneRange();
if (!this.options.textControls?.includes(event.relatedTarget)) this.commitText();
});
input.addEventListener("focus", () => {
if (editing.range) {
const selection = document.getSelection();
selection.removeAllRanges();
selection.addRange(editing.range);
editing.range = null;
}
}); });
input.addEventListener("keydown", (event) => { input.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "s") return; if ((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "s") return;
event.stopPropagation(); event.stopPropagation();
if (event.isComposing || this.input?.saving) { if (event.isComposing || editing.composing || editing.saving) {
return; return;
} }
if (event.key === "Escape") { if (event.key === "Escape") {
@@ -972,18 +1070,42 @@ export class CanvasEditor {
editing.input.focus({ preventScroll: true }); editing.input.focus({ preventScroll: true });
} }
setTextStyle(values) {
const editing = this.input;
const style = { ...editing.style, ...values };
for (const key of Object.keys(style)) if (style[key] === editing.item[key]) delete style[key];
editing.style = Object.keys(style).length ? style : null;
const item = { ...editing.item, ...editing.style };
editing.input.style.fontSize = `${item.size * PX_PER_MM}px`;
editing.input.style.color = item.color;
this.options.onTextChange();
}
textChanged() {
const editing = this.input;
if (!editing) return false;
const value = editTextValue(editing.input);
return (!editing.item.draft || Boolean(value.trim()))
&& (value !== editing.item.text || Boolean(editing.fontData || editing.style));
}
async commitText() { async commitText() {
const editing = this.input; const editing = this.input;
if (!editing) return true; if (!editing) return true;
if (editing.saving) return false; if (editing.saving) return false;
if (editing.composing) {
editing.commitAfterComposition = true;
return false;
}
editing.commitAfterComposition = false;
const value = editTextValue(editing.input); const value = editTextValue(editing.input);
if (value === editing.item.text && !editing.fontData || editing.item.draft && !value.trim()) { if (!this.textChanged()) {
this.closeText(); this.closeText();
return true; return true;
} }
editing.saving = true; editing.saving = true;
editing.input.contentEditable = "false"; editing.input.contentEditable = "false";
const saved = await this.options.onCommitText(editing.item, value, editing.fontData); const saved = await this.options.onCommitText(editing.style ? { ...editing.item, ...editing.style } : editing.item, value, editing.fontData, editing.style?.color ?? null);
if (this.input !== editing) { if (this.input !== editing) {
return Boolean(saved); return Boolean(saved);
} }
+5
View File
@@ -49,6 +49,11 @@ async function handleMessage({ id, name, args }) {
self.postMessage({ id, type: "export", stage: "pages", completed, total }); self.postMessage({ id, type: "export", stage: "pages", completed, total });
finish(done); finish(done);
}); });
} else if (name === "ofdgoSaveDocument") {
args.push((phase, completed, total, done) => {
self.postMessage({ id, type: "export", stage: "prepare", phase, completed, total });
finish(done);
});
} }
} }
const payload = await globalThis[name](...args); const payload = await globalThis[name](...args);
+53 -13
View File
@@ -30,6 +30,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"syscall/js" "syscall/js"
"time"
"github.com/xiaoqidun/ofdgo" "github.com/xiaoqidun/ofdgo"
) )
@@ -608,11 +609,11 @@ var copiedObjects *editorClipboard
// editorInfo 编辑文档信息与操作状态 // editorInfo 编辑文档信息与操作状态
type editorInfo struct { type editorInfo struct {
DocumentInfo DocumentInfo
Revision uint64 `json:"revision"` Revision uint64 `json:"revision"`
CanUndo bool `json:"canUndo"` CanUndo bool `json:"canUndo"`
CanRedo bool `json:"canRedo"` CanRedo bool `json:"canRedo"`
PageCapabilities []map[string]bool `json:"pageCapabilities"` PageCapabilities []uint `json:"pageCapabilities"`
EditWarnings []string `json:"editWarnings,omitempty"` EditWarnings []string `json:"editWarnings,omitempty"`
} }
// editorPageInfo 页面操作结果与目标页面 // editorPageInfo 页面操作结果与目标页面
@@ -637,13 +638,27 @@ func editorSummary() editorInfo {
if currentSession.doc.Signatures != "" { if currentSession.doc.Signatures != "" {
info.EditWarnings = append(info.EditWarnings, "修改文档可能使原签名失效") info.EditWarnings = append(info.EditWarnings, "修改文档可能使原签名失效")
} }
for i := 0; i < currentEditor.PageCount(); i++ { info.PageCapabilities = make([]uint, currentEditor.PageCount())
for i := range info.PageCapabilities {
capability, _ := currentEditor.PageCapabilities(i) capability, _ := currentEditor.PageCapabilities(i)
info.PageCapabilities = append(info.PageCapabilities, map[string]bool{"insert": capability.Insert, "copy": capability.Copy, "delete": capability.Delete, "move": capability.Move, "resize": capability.Resize}) info.PageCapabilities[i] = pageCapabilityMask(capability)
} }
return info return info
} }
// pageCapabilityMask 按插入、复制、删除、移动和尺寸的固定顺序编码页面能力,避免重复传输字段名
// 入参: capability 页面能力
// 返回: uint 能力位标记
func pageCapabilityMask(capability ofdgo.PageCapabilities) uint {
var mask uint
for i, enabled := range [...]bool{capability.Insert, capability.Copy, capability.Delete, capability.Move, capability.Resize} {
if enabled {
mask |= 1 << i
}
}
return mask
}
// restoreEditor 撤销或重做并更新预览 // restoreEditor 撤销或重做并更新预览
// 入参: redo 是否重做 // 入参: redo 是否重做
// 返回: any 文档信息, error 错误信息 // 返回: any 文档信息, error 错误信息
@@ -1055,8 +1070,8 @@ func changePage(args []js.Value) (any, error) {
return editorPageInfo{info, index}, nil return editorPageInfo{info, index}, nil
} }
// batchPages 按页码范围批量复制或删除页面 // batchPages 按页码范围批量复制、删除或移动页面
// 入参: args 操作及页码表达式 // 入参: args 操作、页码表达式及原页面序列中的插入位置
// 返回: any 文档信息及目标页, error 错误信息 // 返回: any 文档信息及目标页, error 错误信息
func batchPages(args []js.Value) (any, error) { func batchPages(args []js.Value) (any, error) {
index := 0 index := 0
@@ -1081,6 +1096,18 @@ func batchPages(args []js.Value) (any, error) {
} }
index = min(indexes[0], currentEditor.PageCount()-1) index = min(indexes[0], currentEditor.PageCount()-1)
return nil return nil
case "move":
at := args[2].Int()
if at < 0 || at > currentEditor.PageCount() {
return fmt.Errorf("page index %d out of range", at)
}
index = at
for _, selected := range indexes {
if selected < at {
index--
}
}
return currentEditor.MovePages(indexes, index)
default: default:
return fmt.Errorf("unsupported page action %q", args[0].String()) return fmt.Errorf("unsupported page action %q", args[0].String())
} }
@@ -1256,7 +1283,7 @@ func loadImport(args []js.Value) (any, error) {
} }
// importPages 插入指定来源页面并保持目标文档元数据 // importPages 插入指定来源页面并保持目标文档元数据
// 入参: args 页码表达式,空值为全部,以及目标零基插入位置 // 入参: args 页码表达式,空值为全部、目标零基插入位置和是否导入对应目录
// 返回: any 文档信息及首个插入页, error 错误信息 // 返回: any 文档信息及首个插入页, error 错误信息
func importPages(args []js.Value) (any, error) { func importPages(args []js.Value) (any, error) {
if currentEditor == nil || pendingImport == nil { if currentEditor == nil || pendingImport == nil {
@@ -1278,7 +1305,8 @@ func importPages(args []js.Value) (any, error) {
} }
} }
at := args[1].Int() at := args[1].Int()
if _, err := currentEditor.ImportPages(pendingImport, indexes, at); err != nil { options := ofdgo.PageImportOptions{Outlines: len(args) > 2 && args[2].Bool()}
if _, err := currentEditor.ImportPagesWithOptions(pendingImport, indexes, at, options); err != nil {
return nil, err return nil, err
} }
clearImport() clearImport()
@@ -1881,7 +1909,7 @@ func insertImage(args []js.Value) (any, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if height := pageBox.H - box.Y*2; box.H > height { if height := pageBox.H - box.Y; box.H > height {
box.W *= height / box.H box.W *= height / box.H
box.H = height box.H = height
} }
@@ -1895,12 +1923,24 @@ func insertImage(args []js.Value) (any, error) {
} }
// saveDocument 分块写出当前编辑文档 // saveDocument 分块写出当前编辑文档
// 入参: args 数据写出回调 // 入参: args 数据写出回调和可选的准备进度回调
// 返回: any 保存结果, error 错误信息 // 返回: any 保存结果, error 错误信息
func saveDocument(args []js.Value) (any, error) { func saveDocument(args []js.Value) (any, error) {
if currentEditor == nil { if currentEditor == nil {
return nil, fmt.Errorf("no document is being edited") return nil, fmt.Errorf("no document is being edited")
} }
if len(args) > 1 {
var lastStage string
var lastProgress time.Time
currentEditor.OnWriteProgress = func(stage string, completed, total int) error {
if stage == lastStage && completed != total && time.Since(lastProgress) < 32*time.Millisecond {
return nil
}
lastStage, lastProgress = stage, time.Now()
return awaitExport(args[1], stage, completed, total)
}
defer func() { currentEditor.OnWriteProgress = nil }()
}
writer := bufio.NewWriterSize(exportWriter{write: args[0]}, 1<<20) writer := bufio.NewWriterSize(exportWriter{write: args[0]}, 1<<20)
if _, err := currentEditor.WriteTo(writer); err != nil { if _, err := currentEditor.WriteTo(writer); err != nil {
return nil, err return nil, err
+77 -19
View File
@@ -36,29 +36,34 @@ import (
// Editor 编辑OFD文档,长度单位为毫米,页面索引从0开始,实例需串行使用 // Editor 编辑OFD文档,长度单位为毫米,页面索引从0开始,实例需串行使用
// Info可修改文档元数据,通过方法管理页面、对象和资源,WriteTo另存结果,不覆盖输入 // Info可修改文档元数据,通过方法管理页面、对象和资源,WriteTo另存结果,不覆盖输入
// OnWriteProgress可选,按fonts、pages、references、resources、write阶段同步回报准备进度,返回错误则停止保存
// completed和total为当前阶段已处理及总工作项,write阶段不计数;阶段可重复,准备完成不代表保存成功
// 回调不可重入修改编辑器,可返回context.Canceled等调用方停止原因
type Editor struct { type Editor struct {
Info DocInfo OnWriteProgress func(stage string, completed, total int) error
pages []PageContent Info DocInfo
resources []editorResource pages []PageContent
fonts map[string]*font.SFNT resources []editorResource
images map[string]image.Point fonts map[string]*font.SFNT
resourceID map[editorResourceKey]string images map[string]image.Point
maxID int resourceID map[editorResourceKey]string
history []editorChange maxID int
historyIndex int history []editorChange
historyLimit int historyIndex int
revision uint64 historyLimit int
serial uint64 revision uint64
source *editorSource serial uint64
outlines []byte source *editorSource
outlines []byte
} }
// editorResource 文档内嵌资源 // editorResource 文档内嵌资源
type editorResource struct { type editorResource struct {
name string name string
data []byte data []byte
font *Font font *Font
image *MultiMedia image *MultiMedia
subset *editorFontSubset
} }
// editorResourceKey 资源内容和字体集合索引 // editorResourceKey 资源内容和字体集合索引
@@ -198,7 +203,7 @@ func (e *Editor) CopyPages(indexes []int) ([]int, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if _, err := e.importPages(source, indexes, at, true); err != nil { if _, err := e.importPages(source, indexes, at, true, PageImportOptions{}); err != nil {
return nil, err return nil, err
} }
result := make([]int, len(indexes)) result := make([]int, len(indexes))
@@ -358,6 +363,59 @@ func (e *Editor) MovePage(from, to int) error {
return nil return nil
} }
// MovePages 按指定顺序将页面移动到移除这些页面后的目标位置,保留页面及对象标识
// 一次操作计入一条撤销记录,不解析或重写页面内容
// 入参: indexes 不重复的原页面索引, to 移动后的起始页面索引,最大值为总页数减去移动页数
// 返回: error 错误信息
func (e *Editor) MovePages(indexes []int, to int) error {
if err := e.validatePageIndexes(indexes); err != nil {
return err
}
if to < 0 || to > len(e.pages)-len(indexes) {
return fmt.Errorf("page index %d out of range", to)
}
if len(indexes) == 0 {
return nil
}
selected := make(map[int]bool, len(indexes))
for _, index := range indexes {
selected[index] = true
}
order := make([]int, 0, len(e.pages))
for index := range e.pages {
if !selected[index] {
order = append(order, index)
}
}
order = slices.Insert(order, to, indexes...)
changed := false
for index, previous := range order {
changed = changed || index != previous
}
if !changed {
return nil
}
e.reorderPages(order)
if change := e.recordChange(); change != nil {
inverse := make([]int, len(order))
for index, previous := range order {
inverse[previous] = index
}
change.undo = func(e *Editor) { e.reorderPages(inverse) }
change.redo = func(e *Editor) { e.reorderPages(order) }
}
return nil
}
// reorderPages 按原页面索引序列调整页面顺序
// 入参: order 页面索引序列
func (e *Editor) reorderPages(order []int) {
pages := slices.Clone(e.pages)
for index, previous := range order {
e.pages[index] = pages[previous]
}
}
// ResizePage 调整页面尺寸,不缩放或移动页面内的对象 // ResizePage 调整页面尺寸,不缩放或移动页面内的对象
// 入参: index 页面索引, width 页面宽度, height 页面高度 // 入参: index 页面索引, width 页面宽度, height 页面高度
// 返回: error 错误信息 // 返回: error 错误信息
+38 -10
View File
@@ -176,9 +176,16 @@ func subsetSourceFont(data []byte, usage *editorFontUsage) []byte {
return result return result
} }
// subsetFonts 收集新增字体实际使用的字形,仅裁剪保存结果,不修改编辑资源或原文档字体 // editorFontSubset 保存单个新增字体最近一次裁剪结果,不保留历史字形集合
type editorFontSubset struct {
glyphs []uint16
data []byte
}
// subsetFonts 收集新增字体实际使用的字形,仅裁剪保存结果,保留完整编辑字体
// 入参: progress 保存进度回调
// 返回: map[string][]byte 包内字体子集, error 错误信息 // 返回: map[string][]byte 包内字体子集, error 错误信息
func (e *Editor) subsetFonts() (map[string][]byte, error) { func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error) {
used := make(map[string]map[uint16]bool) used := make(map[string]map[uint16]bool)
for _, resource := range e.resources { for _, resource := range e.resources {
if resource.font == nil { if resource.font == nil {
@@ -189,7 +196,11 @@ func (e *Editor) subsetFonts() (map[string][]byte, error) {
used[resource.font.ID] = make(map[uint16]bool) used[resource.font.ID] = make(map[uint16]bool)
} }
} }
for _, page := range e.pages { total := len(e.pages) + len(e.resources)
for i, page := range e.pages {
if err := progress.report("fonts", i, total); err != nil {
return nil, err
}
for _, layer := range page.Content.Layer { for _, layer := range page.Content.Layer {
for _, object := range layer.Objects { for _, object := range layer.Objects {
if object.Type != "TextObject" { if object.Type != "TextObject" {
@@ -209,25 +220,42 @@ func (e *Editor) subsetFonts() (map[string][]byte, error) {
} }
} }
result := make(map[string][]byte) result := make(map[string][]byte)
for _, resource := range e.resources { for i := range e.resources {
if resource.font == nil || len(used[resource.font.ID]) == 0 { if err := progress.report("fonts", len(e.pages)+i, total); err != nil {
return nil, err
}
resource := &e.resources[i]
if resource.font == nil {
continue continue
} }
glyphs := used[resource.font.ID] glyphs := used[resource.font.ID]
if len(glyphs) == 0 {
resource.subset = nil
continue
}
glyphs[0] = true glyphs[0] = true
ids := make([]uint16, 0, len(glyphs)) ids := make([]uint16, 0, len(glyphs))
for id := range glyphs { for id := range glyphs {
ids = append(ids, id) ids = append(ids, id)
} }
slices.Sort(ids) slices.Sort(ids)
data, err := subsetEditorFont(resource.data, ids) if resource.subset == nil || !slices.Equal(resource.subset.glyphs, ids) {
if err != nil { data, err := subsetEditorFont(resource.data, ids)
return nil, fmt.Errorf("subset font %s: %w", resource.font.FontName, err) if err != nil {
return nil, fmt.Errorf("subset font %s: %w", resource.font.FontName, err)
}
if len(data) >= len(resource.data) {
data = nil
}
resource.subset = &editorFontSubset{glyphs: ids, data: data}
} }
if len(data) < len(resource.data) { if resource.subset.data != nil {
result[resource.name] = data result[resource.name] = resource.subset.data
} }
} }
if err := progress.report("fonts", total, total); err != nil {
return nil, err
}
return result, nil return result, nil
} }
+138 -4
View File
@@ -50,6 +50,11 @@ type editorPageImport struct {
copyPage bool copyPage bool
} }
// PageImportOptions 页面导入选项,零值仅导入页面及其关联资源
type PageImportOptions struct {
Outlines bool
}
// ImportPages 将来源主文档中的指定页面插入目标位置,保持输入顺序并作为一次撤销操作 // ImportPages 将来源主文档中的指定页面插入目标位置,保持输入顺序并作为一次撤销操作
// 页面索引从0开始,at可等于当前页数;复制关联模板、注释、签章外观和实际引用的资源 // 页面索引从0开始,at可等于当前页数;复制关联模板、注释、签章外观和实际引用的资源
// 不导入来源元数据和目录,指向未选页面的跳转被移除;签名数据仅保留原始凭据,不代表合并后文档有效 // 不导入来源元数据和目录,指向未选页面的跳转被移除;签名数据仅保留原始凭据,不代表合并后文档有效
@@ -57,13 +62,22 @@ type editorPageImport struct {
// 入参: source 来源阅读器, indexes 来源页面索引,不可重复, at 目标插入位置 // 入参: source 来源阅读器, indexes 来源页面索引,不可重复, at 目标插入位置
// 返回: []string 新页面标识, error 错误信息 // 返回: []string 新页面标识, error 错误信息
func (e *Editor) ImportPages(source *Reader, indexes []int, at int) ([]string, error) { func (e *Editor) ImportPages(source *Reader, indexes []int, at int) ([]string, error) {
return e.importPages(source, indexes, at, false) return e.ImportPagesWithOptions(source, indexes, at, PageImportOptions{})
}
// ImportPagesWithOptions 按选项导入页面,页面与目录作为一次原子操作提交
// Outlines启用时追加指向所选页面的目录及其祖先,保留层级和跳转坐标,不导入无关目录
// 其他行为与ImportPages相同,不迁移来源元数据
// 入参: source 来源阅读器, indexes 来源页面索引, at 目标插入位置, options 导入选项
// 返回: []string 新页面标识, error 错误信息
func (e *Editor) ImportPagesWithOptions(source *Reader, indexes []int, at int, options PageImportOptions) ([]string, error) {
return e.importPages(source, indexes, at, false, options)
} }
// importPages 迁移页面,同文档复制时复用原资源并保留其他页面的跳转 // importPages 迁移页面,同文档复制时复用原资源并保留其他页面的跳转
// 入参: source 来源阅读器, indexes 来源页索引, at 插入位置, copyPage 是否同文档复制 // 入参: source 来源阅读器, indexes 来源页索引, at 插入位置, copyPage 是否同文档复制, options 导入选项
// 返回: []string 新页标识, error 错误信息 // 返回: []string 新页标识, error 错误信息
func (e *Editor) importPages(source *Reader, indexes []int, at int, copyPage bool) ([]string, error) { func (e *Editor) importPages(source *Reader, indexes []int, at int, copyPage bool, options PageImportOptions) ([]string, error) {
if at < 0 || at > len(e.pages) { if at < 0 || at > len(e.pages) {
return nil, fmt.Errorf("page index %d out of range", at) return nil, fmt.Errorf("page index %d out of range", at)
} }
@@ -145,6 +159,17 @@ func (e *Editor) importPages(source *Reader, indexes []int, at int, copyPage boo
if err != nil { if err != nil {
return nil, err return nil, err
} }
beforeOutlines, outlines := e.outlines, e.outlines
if options.Outlines {
imported, err := m.outlines()
if err != nil {
return nil, err
}
outlines, err = e.appendImportedOutlines(imported)
if err != nil {
return nil, err
}
}
if !copyPage { if !copyPage {
var resourceContent []byte var resourceContent []byte
for _, group := range []string{"ColorSpaces", "DrawParams", "Fonts", "MultiMedias", "CompositeGraphicUnits"} { for _, group := range []string{"ColorSpaces", "DrawParams", "Fonts", "MultiMedias", "CompositeGraphicUnits"} {
@@ -217,6 +242,7 @@ func (e *Editor) importPages(source *Reader, indexes []int, at int, copyPage boo
} }
} }
e.source, e.maxID = next, m.maximum e.source, e.maxID = next, m.maximum
e.outlines = outlines
e.pages = slices.Insert(e.pages, at, added...) e.pages = slices.Insert(e.pages, at, added...)
if change := e.recordChange(); change != nil { if change := e.recordChange(); change != nil {
change.undo = func(e *Editor) { change.undo = func(e *Editor) {
@@ -225,6 +251,7 @@ func (e *Editor) importPages(source *Reader, indexes []int, at int, copyPage boo
} }
e.pages = slices.Delete(e.pages, at, at+len(added)) e.pages = slices.Delete(e.pages, at, at+len(added))
e.source = base e.source = base
e.outlines = beforeOutlines
} }
change.redo = func(e *Editor) { change.redo = func(e *Editor) {
pages := make([]PageContent, len(added)) pages := make([]PageContent, len(added))
@@ -233,11 +260,118 @@ func (e *Editor) importPages(source *Reader, indexes []int, at int, copyPage boo
} }
e.pages = slices.Insert(e.pages, at, pages...) e.pages = slices.Insert(e.pages, at, pages...)
e.source = next e.source = next
e.outlines = outlines
} }
} }
return ids, nil return ids, nil
} }
// outlines 筛选指向导入页面的目录树并复用标准动作迁移
// 返回: []byte 目录片段, error 错误信息
func (m *editorPageImport) outlines() ([]byte, error) {
name := m.reader.ResPath(m.reader.OFD.DocBody[0].DocRoot)
data, err := m.reader.readFile(name)
if err != nil {
return nil, err
}
root, err := parseEditorXML(data)
if err != nil {
return nil, err
}
root = root.child("Outlines")
if root == nil {
return nil, nil
}
bookmarks := make(map[string]Dest, len(m.doc.Bookmarks.Bookmark))
for _, bookmark := range m.doc.Bookmarks.Bookmark {
bookmarks[bookmark.Name] = bookmark.Dest
}
var patches []editorXMLPatch
var keep func(*editorXML) (bool, error)
keep = func(node *editorXML) (bool, error) {
start, selected := len(patches), false
if actions := node.child("Actions"); actions != nil {
var value struct {
Action []Action `xml:"Action"`
}
if err := xml.Unmarshal(data[actions.start:actions.end], &value); err != nil {
return false, err
}
for _, action := range value.Action {
if action.Goto != nil {
if dest := gotoDest(action.Goto, bookmarks); dest != nil && m.pages[dest.PageID] {
selected = true
}
}
}
}
for _, child := range editorOutlineChildren(node) {
retained, err := keep(child)
if err != nil {
return false, err
}
selected = selected || retained
}
if !selected {
patches = append(patches[:start], editorXMLPatch{node.start, node.end, nil})
}
return selected, nil
}
for _, node := range editorOutlineChildren(root) {
if _, err := keep(node); err != nil {
return nil, err
}
}
data = editorPatchXML(data, patches)
root, err = parseEditorXML(data)
if err != nil {
return nil, err
}
root = root.child("Outlines")
if len(editorOutlineChildren(root)) == 0 {
return nil, nil
}
return m.encode(editorImportEntry{name: name, data: data}, root)
}
// appendImportedOutlines 将已迁移目录追加到目标根目录,规范计数且不修改当前编辑器
// 入参: imported 已迁移目录片段
// 返回: []byte 合并后的目录, error 错误信息
func (e *Editor) appendImportedOutlines(imported []byte) ([]byte, error) {
if len(imported) == 0 {
return e.outlines, nil
}
added, err := parseEditorXML(imported)
if 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
}
var content []byte
if root.open != root.end {
content = bytes.Clone(data[root.open:root.close])
}
for _, child := range editorOutlineChildren(added) {
value, err := editorXMLStandalone(imported[child.start:child.end], child)
if err != nil {
return nil, err
}
content = append(content, value...)
}
preview := *e
preview.historyLimit = 0
if err := preview.setOutlineXML(editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, root, content)})); err != nil {
return nil, err
}
return preview.outlines, nil
}
// importBase 为新建文档提供空的保真包基底,不重复保存已创建的页面和资源 // importBase 为新建文档提供空的保真包基底,不重复保存已创建的页面和资源
// 返回: *editorSource 包基底, error 错误信息 // 返回: *editorSource 包基底, error 错误信息
func (e *Editor) importBase() (*editorSource, error) { func (e *Editor) importBase() (*editorSource, error) {
@@ -250,7 +384,7 @@ func (e *Editor) importBase() (*editorSource, error) {
files[name] = data files[name] = data
} }
return nil return nil
}); err != nil { }, nil); err != nil {
return nil, err return nil, err
} }
data := files["Doc_0/Document.xml"] data := files["Doc_0/Document.xml"]
+21 -6
View File
@@ -29,13 +29,17 @@ import (
) )
// sourceParts 生成编辑快照的XML和新增资源,删页时同步清理标准引用 // sourceParts 生成编辑快照的XML和新增资源,删页时同步清理标准引用
// 入参: progress 保存进度回调,预览时为nil
// 返回: map[string][]byte 替换及新增条目, error 错误信息 // 返回: map[string][]byte 替换及新增条目, error 错误信息
func (e *Editor) sourceParts() (map[string][]byte, error) { func (e *Editor) sourceParts(progress editorProgress) (map[string][]byte, error) {
parts := make(map[string][]byte) parts := make(map[string][]byte)
source := e.source source := e.source
reader := source.reader reader := source.reader
pageRefs := make([]Page, len(e.pages)) pageRefs := make([]Page, len(e.pages))
for i, page := range e.pages { for i, page := range e.pages {
if err := progress.report("pages", i, len(e.pages)); err != nil {
return nil, err
}
original := source.pages[page.ID] original := source.pages[page.ID]
if original != nil { if original != nil {
pageRefs[i] = original.ref pageRefs[i] = original.ref
@@ -59,6 +63,9 @@ func (e *Editor) sourceParts() (map[string][]byte, error) {
pageRefs[i] = Page{ID: page.ID, BaseLoc: "/" + name} pageRefs[i] = Page{ID: page.ID, BaseLoc: "/" + name}
} }
} }
if err := progress.report("pages", len(e.pages), len(e.pages)); err != nil {
return nil, err
}
fonts, images, resourceFiles := e.usedResources() fonts, images, resourceFiles := e.usedResources()
commonData := source.document.CommonData commonData := source.document.CommonData
for _, name := range append(slices.Clone(commonData.DocumentRes), commonData.PublicRes...) { for _, name := range append(slices.Clone(commonData.DocumentRes), commonData.PublicRes...) {
@@ -393,15 +400,15 @@ func (e *Editor) sourceInfoXML() ([]byte, error) {
} }
// writeSource 将未修改ZIP条目直接复制到新包,逐项写入改动,不持有原资源解压副本 // writeSource 将未修改ZIP条目直接复制到新包,逐项写入改动,不持有原资源解压副本
// 入参: writer 输出流, fonts 新增字体子集 // 入参: writer 输出流, fonts 新增字体子集, progress 保存进度回调
// 返回: int64 写入字节数, error 错误信息 // 返回: int64 写入字节数, error 错误信息
func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte) (int64, error) { func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte, progress editorProgress) (int64, error) {
parts, err := e.sourceParts() parts, err := e.sourceParts(progress)
if err != nil { if err != nil {
return 0, err return 0, err
} }
maps.Copy(parts, fonts) maps.Copy(parts, fonts)
removed, err := e.compactSourceResources(parts) removed, err := e.compactSourceResources(parts, progress)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -416,6 +423,9 @@ func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte) (int64,
delete(remaining, name) delete(remaining, name)
} }
} }
if err := progress.report("write", 0, 0); err != nil {
return 0, err
}
output := &ofdCountingWriter{writer: writer} output := &ofdCountingWriter{writer: writer}
archive := zip.NewWriter(output) archive := zip.NewWriter(output)
if reader.Zip != nil { if reader.Zip != nil {
@@ -467,7 +477,7 @@ func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte) (int64,
// sourceReader 生成原包与修改条目组成的独立预览快照,不进行ZIP压缩 // sourceReader 生成原包与修改条目组成的独立预览快照,不进行ZIP压缩
// 返回: *Reader 阅读器, error 错误信息 // 返回: *Reader 阅读器, error 错误信息
func (e *Editor) sourceReader() (*Reader, error) { func (e *Editor) sourceReader() (*Reader, error) {
parts, err := e.sourceParts() parts, err := e.sourceParts(nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -483,5 +493,10 @@ func (e *Editor) sourceReader() (*Reader, error) {
if _, err := reader.Doc(); err != nil { if _, err := reader.Doc(); err != nil {
return nil, err return nil, err
} }
for name, header := range e.source.reader.pageHeaderCache {
if _, changed := parts[name]; !changed {
reader.pageHeaderCache[name] = header
}
}
return reader, nil return reader, nil
} }
+24 -5
View File
@@ -36,9 +36,9 @@ type editorResourceRefs struct {
// compactSourceResources 在保存副本中清理无引用字体和图片,并裁剪可完整确认用字的原有字体 // compactSourceResources 在保存副本中清理无引用字体和图片,并裁剪可完整确认用字的原有字体
// 按GB/T 33190-2016附录A检查标识和路径引用,包括模板、注释、底纹、裁剪、版本和其他文档 // 按GB/T 33190-2016附录A检查标识和路径引用,包括模板、注释、底纹、裁剪、版本和其他文档
// 全包检查保留孤立XML中的引用;未知命名空间、扩展数据或无法解析的XML使本次清理跳过 // 全包检查保留孤立XML中的引用;未知命名空间、扩展数据或无法解析的XML使本次清理跳过
// 入参: parts 已修改和新增的包内条目 // 入参: parts 已修改和新增的包内条目, progress 保存进度回调
// 返回: map[string]bool 可移除的二进制条目, error 读取错误 // 返回: map[string]bool 可移除的二进制条目, error 读取错误
func (e *Editor) compactSourceResources(parts map[string][]byte) (map[string]bool, error) { func (e *Editor) compactSourceResources(parts map[string][]byte, progress editorProgress) (map[string]bool, error) {
if len(parts) == 0 { if len(parts) == 0 {
return nil, nil return nil, nil
} }
@@ -55,7 +55,11 @@ func (e *Editor) compactSourceResources(parts map[string][]byte) (map[string]boo
} }
refs := editorResourceRefs{ids: make(map[string]bool), files: make(map[string]bool), fonts: make(map[string]*editorFontUsage)} refs := editorResourceRefs{ids: make(map[string]bool), files: make(map[string]bool), fonts: make(map[string]*editorFontUsage)}
var resources []string var resources []string
for _, name := range slices.Sorted(maps.Keys(names)) { ordered := slices.Sorted(maps.Keys(names))
for i, name := range ordered {
if err := progress.report("references", i, len(ordered)); err != nil {
return nil, err
}
if strings.HasSuffix(name, "/") { if strings.HasSuffix(name, "/") {
continue continue
} }
@@ -78,11 +82,17 @@ func (e *Editor) compactSourceResources(parts map[string][]byte) (map[string]boo
resources = append(resources, name) resources = append(resources, name)
} }
} }
if err := progress.report("references", len(ordered), len(ordered)); err != nil {
return nil, err
}
removed := make(map[string]bool) removed := make(map[string]bool)
usedFiles := maps.Clone(refs.files) usedFiles := maps.Clone(refs.files)
updates := make(map[string][]byte) updates := make(map[string][]byte)
fontFiles := make(map[string]*editorFontUsage) fontFiles := make(map[string]*editorFontUsage)
for _, name := range resources { for i, name := range resources {
if err := progress.report("resources", i, len(resources)); err != nil {
return nil, err
}
data, ok := parts[name] data, ok := parts[name]
if !ok { if !ok {
var err error var err error
@@ -150,10 +160,16 @@ func (e *Editor) compactSourceResources(parts map[string][]byte) (map[string]boo
updates[name] = editorPatchXML(data, patches) updates[name] = editorPatchXML(data, patches)
} }
} }
if err := progress.report("resources", len(resources), len(resources)); err != nil {
return nil, err
}
for name := range usedFiles { for name := range usedFiles {
delete(removed, name) delete(removed, name)
} }
for name := range names { for i, name := range ordered {
if err := progress.report("fonts", i, len(ordered)); err != nil {
return nil, err
}
key := strings.ToLower(cleanPackagePath(name)) key := strings.ToLower(cleanPackagePath(name))
usage := fontFiles[key] usage := fontFiles[key]
if usage == nil || usage.unsafe || refs.files[key] || removed[key] || parts[name] != nil { if usage == nil || usage.unsafe || refs.files[key] || removed[key] || parts[name] != nil {
@@ -167,6 +183,9 @@ func (e *Editor) compactSourceResources(parts map[string][]byte) (map[string]boo
updates[name] = subset updates[name] = subset
} }
} }
if err := progress.report("fonts", len(ordered), len(ordered)); err != nil {
return nil, err
}
maps.Copy(parts, updates) maps.Copy(parts, updates)
for name := range parts { for name := range parts {
if removed[strings.ToLower(cleanPackagePath(name))] { if removed[strings.ToLower(cleanPackagePath(name))] {
+32 -6
View File
@@ -29,6 +29,19 @@ import (
const ofdNamespace = "http://www.ofdspec.org/2016" const ofdNamespace = "http://www.ofdspec.org/2016"
// editorProgress 保存准备阶段的进度检查点,内存预览使用nil
type editorProgress func(stage string, completed, total int) error
// report 回报进度并原样传递调用方停止原因
// 入参: stage 阶段, completed 已处理项, total 总项数
// 返回: error 调用方停止原因
func (progress editorProgress) report(stage string, completed, total int) error {
if progress != nil {
return progress(stage, completed, total)
}
return nil
}
// WriteTo 逐个条目写出OFD,不关闭调用方输出流,出错时应丢弃本次输出 // WriteTo 逐个条目写出OFD,不关闭调用方输出流,出错时应丢弃本次输出
// 新增资源仅写入实际引用的部分,静态TrueType轮廓字体按实际文字生成子集 // 新增资源仅写入实际引用的部分,静态TrueType轮廓字体按实际文字生成子集
// 原有字体在全包引用可确定时保留字形编号裁剪;编辑资源、复杂字体及无法确定的引用保持原样 // 原有字体在全包引用可确定时保留字形编号裁剪;编辑资源、复杂字体及无法确定的引用保持原样
@@ -39,12 +52,16 @@ func (e *Editor) WriteTo(writer io.Writer) (int64, error) {
if err := e.validate(); err != nil { if err := e.validate(); err != nil {
return 0, err return 0, err
} }
fonts, err := e.subsetFonts() progress := editorProgress(e.OnWriteProgress)
fonts, err := e.subsetFonts(progress)
if err != nil { if err != nil {
return 0, err return 0, err
} }
if e.source != nil { if e.source != nil {
return e.writeSource(writer, fonts) return e.writeSource(writer, fonts, progress)
}
if err := progress.report("write", 0, 0); err != nil {
return 0, err
} }
output := &ofdCountingWriter{writer: writer} output := &ofdCountingWriter{writer: writer}
archive := zip.NewWriter(output) archive := zip.NewWriter(output)
@@ -62,7 +79,7 @@ func (e *Editor) WriteTo(writer io.Writer) (int64, error) {
} }
_, err = entry.Write(data) _, err = entry.Write(data)
return err return err
}) }, progress)
if err == nil { if err == nil {
err = archive.Close() err = archive.Close()
} }
@@ -83,7 +100,7 @@ func (e *Editor) Reader() (*Reader, error) {
if err := e.writeParts(func(name string, data []byte, _ bool) error { if err := e.writeParts(func(name string, data []byte, _ bool) error {
r.files[name] = data r.files[name] = data
return nil return nil
}); err != nil { }, nil); err != nil {
return nil, err return nil, err
} }
if err := r.initRoot(); err != nil { if err := r.initRoot(); err != nil {
@@ -92,6 +109,9 @@ func (e *Editor) Reader() (*Reader, error) {
if _, err := r.Doc(); err != nil { if _, err := r.Doc(); err != nil {
return nil, err return nil, err
} }
for i, page := range r.doc.Pages.Page {
r.pageHeaderCache[r.ResPath(page.BaseLoc)] = PageContent{Area: PageArea{PhysicalBox: e.pages[i].Area.PhysicalBox}}
}
return r, nil return r, nil
} }
@@ -120,9 +140,9 @@ func (e *Editor) validate() error {
} }
// writeParts 生成根节点、文档、资源索引、页面及二进制资源 // writeParts 生成根节点、文档、资源索引、页面及二进制资源
// 入参: write 条目写入方法 // 入参: write 条目写入方法, progress 保存进度回调,预览时为nil
// 返回: error 错误信息 // 返回: error 错误信息
func (e *Editor) writeParts(write func(string, []byte, bool) error) error { func (e *Editor) writeParts(write func(string, []byte, bool) error, progress editorProgress) error {
fonts, images, _ := e.usedResources() fonts, images, _ := e.usedResources()
writeXML := func(name string, encode func(*ofdXML)) error { writeXML := func(name string, encode func(*ofdXML)) error {
data, err := encodeOFDXML(encode) data, err := encodeOFDXML(encode)
@@ -198,12 +218,18 @@ func (e *Editor) writeParts(write func(string, []byte, bool) error) error {
} }
} }
for i, page := range e.pages { for i, page := range e.pages {
if err := progress.report("pages", i, len(e.pages)); err != nil {
return err
}
if err := writeXML(fmt.Sprintf("Doc_0/Pages/%d/Content.xml", i+1), func(x *ofdXML) { if err := writeXML(fmt.Sprintf("Doc_0/Pages/%d/Content.xml", i+1), func(x *ofdXML) {
x.page(page) x.page(page)
}); err != nil { }); err != nil {
return err return err
} }
} }
if err := progress.report("pages", len(e.pages), len(e.pages)); err != nil {
return err
}
for _, resources := range [][]editorResource{fonts, images} { for _, resources := range [][]editorResource{fonts, images} {
for _, resource := range resources { for _, resource := range resources {
if err := write(resource.name, resource.data, resource.image != nil); err != nil { if err := write(resource.name, resource.data, resource.image != nil); err != nil {