feat(加密解密): 完善加密管理与保存继承
build.yaml / build (push) Successful in 45s

This commit is contained in:
xiaoqidun committed 2026-09-30 01:35:24 +08:00
1 parent d56a84f92a
commit b0b194895a
8 files changed
+263 -109

No files matched your search

+13 -8
View File
@@ -489,16 +489,21 @@
<div class="form-actions"><div id="credentialsStatus" class="form-status" role="status" aria-live="polite"></div><button id="credentialsCancel" class="button" type="button">取消</button><button class="button" type="submit">解锁</button></div> <div class="form-actions"><div id="credentialsStatus" class="form-status" role="status" aria-live="polite"></div><button id="credentialsCancel" class="button" type="button">取消</button><button class="button" type="submit">解锁</button></div>
</form> </form>
</dialog> </dialog>
<dialog id="encryptionPanel" class="form-dialog" aria-labelledby="encryptionTitle">
<form id="encryptionForm">
<h2 id="encryptionTitle" class="form-title">加密设置</h2>
<div class="form-row"><label for="encryptionType">方式</label><select id="encryptionType" autofocus><option value="none">明文</option><option value="password">口令</option><option value="certificate">证书</option><option id="encryptionKeep" value="keep" hidden>保留</option></select></div>
<div id="encryptionCurrentRow" class="form-row" hidden><span>状态</span><div class="encryption-current"><span id="encryptionCurrent"></span><button id="encryptionChange" class="button" type="button">修改</button></div></div>
<div id="encryptionUserRow" class="form-row"><label for="encryptionUser">用户</label><input id="encryptionUser" type="text" autocomplete="off" spellcheck="false"></div>
<div id="encryptionPasswordRow" class="form-row"><label for="encryptionPassword">口令</label><input id="encryptionPassword" type="password" autocomplete="new-password"></div>
<div id="encryptionConfirmRow" class="form-row"><label for="encryptionConfirm">确认</label><input id="encryptionConfirm" type="password" autocomplete="new-password"></div>
<div id="encryptionCertificatesRow" class="form-row" hidden><label for="encryptionCertificates">证书</label><input id="encryptionCertificates" type="file" accept=".pem,.cer,.crt,.der" multiple></div>
<div class="form-actions"><div id="encryptionStatus" class="form-status" role="status" aria-live="polite"></div><button id="encryptionCancel" class="button" type="button">取消</button><button id="encryptionSubmit" class="button" type="submit">确定</button></div>
</form>
</dialog>
<dialog id="exportPanel" class="form-dialog" aria-labelledby="exportTitle"> <dialog id="exportPanel" class="form-dialog" aria-labelledby="exportTitle">
<form id="exportForm"> <form id="exportForm">
<h2 id="exportTitle" class="form-title">导出文档</h2> <h2 id="exportTitle" class="form-title">导出文档</h2>
<div id="encryptionFields" hidden>
<div class="form-row"><label for="encryptionType">方式</label><select id="encryptionType"><option value="password">口令</option><option value="certificate">证书</option></select></div>
<div id="encryptionUserRow" class="form-row"><label for="encryptionUser">用户</label><input id="encryptionUser" type="text" autocomplete="off" spellcheck="false"></div>
<div id="encryptionPasswordRow" class="form-row"><label for="encryptionPassword">口令</label><input id="encryptionPassword" type="password" autocomplete="new-password"></div>
<div id="encryptionConfirmRow" class="form-row"><label for="encryptionConfirm">确认</label><input id="encryptionConfirm" type="password" autocomplete="new-password"></div>
<div id="encryptionCertificatesRow" class="form-row" hidden><label for="encryptionCertificates">证书</label><input id="encryptionCertificates" type="file" accept=".pem,.cer,.crt,.der" multiple></div>
</div>
<div class="form-row"> <div class="form-row">
<span id="exportScopeLabel">范围</span> <span id="exportScopeLabel">范围</span>
<div class="form-options" role="radiogroup" aria-labelledby="exportScopeLabel"> <div class="form-options" role="radiogroup" aria-labelledby="exportScopeLabel">
@@ -631,7 +636,7 @@
<div id="conversionWarnings" class="attachment-detail"></div> <div id="conversionWarnings" class="attachment-detail"></div>
</details> </details>
<section id="securityPanel" class="security-panel" aria-labelledby="securityPanelTitle" hidden> <section id="securityPanel" class="security-panel" aria-labelledby="securityPanelTitle" hidden>
<div class="panel-row"><div id="securityPanelTitle" class="panel-title compact">安全</div><div class="panel-actions"><button id="encryptSaveButton" class="small-button" type="button" title="加密另存" aria-label="加密另存" aria-haspopup="dialog" aria-controls="exportPanel" disabled>加密</button><button id="signButton" class="small-button" type="button" aria-haspopup="dialog" aria-controls="signPanel" disabled>签章</button><button id="verifyButton" class="small-button" type="button" aria-haspopup="dialog" aria-controls="verifyPanel" disabled>验签</button></div></div> <div class="panel-row"><div id="securityPanelTitle" class="panel-title compact">安全</div><div class="panel-actions"><button id="encryptSaveButton" class="small-button" type="button" title="加密设置" aria-label="加密设置" aria-haspopup="dialog" aria-controls="encryptionPanel" disabled>加密</button><button id="signButton" class="small-button" type="button" aria-haspopup="dialog" aria-controls="signPanel" disabled>签章</button><button id="verifyButton" class="small-button" type="button" aria-haspopup="dialog" aria-controls="verifyPanel" disabled>验签</button></div></div>
<dl class="meta-list"><div><dt>加密</dt><dd id="securityEncryption">未加密</dd></div><div id="securityMethodRow" hidden><dt>算法</dt><dd id="securityMethod"></dd></div></dl> <dl class="meta-list"><div><dt>加密</dt><dd id="securityEncryption">未加密</dd></div><div id="securityMethodRow" hidden><dt>算法</dt><dd id="securityMethod"></dd></div></dl>
</section> </section>
<section id="attachmentPanel" class="attachment-panel" aria-labelledby="attachmentPanelTitle" hidden> <section id="attachmentPanel" class="attachment-panel" aria-labelledby="attachmentPanelTitle" hidden>
+17
View File
@@ -1135,6 +1135,23 @@ button:not(:disabled):hover {
gap: 12px; gap: 12px;
} }
.encryption-current {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: var(--control-height);
}
.encryption-current > span {
min-width: 0;
overflow-wrap: anywhere;
}
.encryption-current > button {
flex-shrink: 0;
}
.annotation-placement { .annotation-placement {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
+131 -58
View File
@@ -51,6 +51,7 @@ const state = {
editorInfo: null, editorInfo: null,
editorViews: new Map(), editorViews: new Map(),
savedRevision: null, savedRevision: null,
encryptionDirty: false,
insertObject: null, insertObject: null,
importPageCount: 0, importPageCount: 0,
importEncrypted: false, importEncrypted: false,
@@ -118,7 +119,7 @@ const state = {
exportFormats: [], exportFormats: [],
exportPages: null, exportPages: null,
exportBackdrop: false, exportBackdrop: false,
exportEncrypted: false, encryptionChanged: false,
showPages: !COMPACT_LAYOUT.matches, showPages: !COMPACT_LAYOUT.matches,
showMeta: !COMPACT_LAYOUT.matches, showMeta: !COMPACT_LAYOUT.matches,
}; };
@@ -178,7 +179,15 @@ const el = {
credentialsPassword: document.querySelector("#credentialsPassword"), credentialsPassword: document.querySelector("#credentialsPassword"),
credentialsStatus: document.querySelector("#credentialsStatus"), credentialsStatus: document.querySelector("#credentialsStatus"),
credentialsCancel: document.querySelector("#credentialsCancel"), credentialsCancel: document.querySelector("#credentialsCancel"),
encryptionFields: document.querySelector("#encryptionFields"), encryptionPanel: document.querySelector("#encryptionPanel"),
encryptionForm: document.querySelector("#encryptionForm"),
encryptionKeep: document.querySelector("#encryptionKeep"),
encryptionCurrentRow: document.querySelector("#encryptionCurrentRow"),
encryptionCurrent: document.querySelector("#encryptionCurrent"),
encryptionChange: document.querySelector("#encryptionChange"),
encryptionCancel: document.querySelector("#encryptionCancel"),
encryptionSubmit: document.querySelector("#encryptionSubmit"),
encryptionStatus: document.querySelector("#encryptionStatus"),
encryptionType: document.querySelector("#encryptionType"), encryptionType: document.querySelector("#encryptionType"),
encryptionUserRow: document.querySelector("#encryptionUserRow"), encryptionUserRow: document.querySelector("#encryptionUserRow"),
encryptionPasswordRow: document.querySelector("#encryptionPasswordRow"), encryptionPasswordRow: document.querySelector("#encryptionPasswordRow"),
@@ -588,7 +597,7 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
}); });
function updatePendingChanges() { function updatePendingChanges() {
setDirty(Boolean(state.editorInfo) && (state.editorInfo.revision !== state.savedRevision setDirty(state.encryptionDirty || Boolean(state.editorInfo) && (state.editorInfo.revision !== state.savedRevision
|| canvasEditor.textChanged() || canvasEditor.cropChanged() || canvasEditor.nudgeChanged())); || canvasEditor.textChanged() || canvasEditor.cropChanged() || canvasEditor.nudgeChanged()));
} }
@@ -1136,7 +1145,7 @@ el.insertPanel.addEventListener("close", () => { state.insertObject = null; });
el.insertForm.addEventListener("submit", insertObject); el.insertForm.addEventListener("submit", insertObject);
el.textFontAdd.addEventListener("click", () => openFontFile(el.fontInput)); el.textFontAdd.addEventListener("click", () => openFontFile(el.fontInput));
el.saveButton.addEventListener("click", () => exportFile(true, null, "ofd")); el.saveButton.addEventListener("click", () => exportFile(true, null, "ofd"));
el.encryptSaveButton.addEventListener("click", () => openExportPanel(true)); editorClick(el.encryptSaveButton, openEncryptionPanel);
el.signButton.addEventListener("click", () => { el.signButton.addEventListener("click", () => {
if (!state.doc || document.body.hasAttribute("aria-busy")) return; if (!state.doc || document.body.hasAttribute("aria-busy")) return;
el.signForm.reset(); el.signForm.reset();
@@ -1259,8 +1268,19 @@ el.outputCompression.addEventListener("change", () => {
el.exportPageButton.addEventListener("click", () => exportFile(false)); el.exportPageButton.addEventListener("click", () => exportFile(false));
el.exportButton.addEventListener("click", () => openExportPanel()); el.exportButton.addEventListener("click", () => openExportPanel());
el.exportCancel.addEventListener("click", () => el.exportPanel.close()); el.exportCancel.addEventListener("click", () => el.exportPanel.close());
el.exportPanel.addEventListener("close", () => { if (!el.exportPanel.open) clearEncryptionForm(); }); el.encryptionPanel.addEventListener("close", () => { if (!el.encryptionPanel.open) clearEncryptionForm(); });
el.encryptionType.addEventListener("change", updateEncryptionType); el.encryptionPanel.addEventListener("cancel", event => { if (el.encryptionForm.inert) event.preventDefault(); });
el.encryptionCancel.addEventListener("click", () => el.encryptionPanel.close());
el.encryptionType.addEventListener("change", () => {
state.encryptionChanged = false;
updateEncryptionType();
});
el.encryptionChange.addEventListener("click", () => {
state.encryptionChanged = true;
updateEncryptionType();
(el.encryptionType.value === "password" ? el.encryptionPassword : el.encryptionCertificates).focus();
});
el.encryptionForm.addEventListener("submit", applyEncryption);
el.cancelExportButton.addEventListener("click", cancelExport); el.cancelExportButton.addEventListener("click", cancelExport);
el.exportPanel.addEventListener("pointerdown", (event) => { el.exportPanel.addEventListener("pointerdown", (event) => {
state.exportBackdrop = event.target === el.exportPanel; state.exportBackdrop = event.target === el.exportPanel;
@@ -1279,29 +1299,6 @@ el.exportRange.addEventListener("input", updateExportRange);
el.exportForm.addEventListener("submit", async (event) => { el.exportForm.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
if (!el.exportSubmit.disabled) { if (!el.exportSubmit.disabled) {
if (state.exportEncrypted) {
const certificates = el.encryptionType.value === "certificate";
if (certificates && !el.encryptionCertificates.files?.length) {
el.exportRangeStatus.textContent = "请选择接收者证书";
return;
}
if (!certificates && !el.encryptionPassword.value) {
el.exportRangeStatus.textContent = "请输入口令";
el.encryptionPassword.focus();
return;
}
if (!certificates && el.encryptionPassword.value !== el.encryptionConfirm.value) {
el.exportRangeStatus.textContent = "口令不一致";
el.encryptionConfirm.focus();
return;
}
const encryption = certificates
? {password:new Uint8Array(), userName:"", recipientFiles:Array.from(el.encryptionCertificates.files)}
: { password: new TextEncoder().encode(el.encryptionPassword.value), userName: el.encryptionUser.value.trim() };
clearEncryptionForm();
try { return await exportFile(true, state.exportPages, "ofd", encryption); }
finally { encryption.password.fill(0); }
}
return exportFile(true, state.exportPages); return exportFile(true, state.exportPages);
} }
}); });
@@ -1494,7 +1491,7 @@ function handleKeyDown(event) {
} }
function formDialogOpen() { function formDialogOpen() {
return Boolean(sealPreview) || el.signPanel.open || el.verifyPanel.open || el.credentialsPanel.open || batchElements.Panel.open || el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open return Boolean(sealPreview) || el.signPanel.open || el.verifyPanel.open || el.credentialsPanel.open || el.encryptionPanel.open || batchElements.Panel.open || 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.sourceTextPanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open || el.annotationCreate.open || el.objectPicker.open; || el.batchPagesPanel.open || el.objectStylePanel.open || el.sourceTextPanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open || el.annotationCreate.open || el.objectPicker.open;
} }
@@ -1918,7 +1915,7 @@ function discardChanges() {
function setEditorInfo(doc) { function setEditorInfo(doc) {
state.editorInfo = { revision: doc.revision, canUndo: doc.canUndo, canRedo: doc.canRedo, state.editorInfo = { revision: doc.revision, canUndo: doc.canUndo, canRedo: doc.canRedo,
pageCapabilities: doc.pageCapabilities, editWarnings: doc.editWarnings || [] }; pageCapabilities: doc.pageCapabilities, editWarnings: doc.editWarnings || [] };
setDirty(doc.revision !== state.savedRevision); setDirty(state.encryptionDirty || doc.revision !== state.savedRevision);
} }
function editorLocation() { function editorLocation() {
@@ -2097,6 +2094,7 @@ async function createDocument(event) {
state.fileName = `${title.replace(/[\\/:*?"<>|]+/g, "_").replace(/\.ofd$/i, "")}.ofd`; state.fileName = `${title.replace(/[\\/:*?"<>|]+/g, "_").replace(/\.ofd$/i, "")}.ofd`;
state.conversionWarnings = []; state.conversionWarnings = [];
state.savedRevision = null; state.savedRevision = null;
state.encryptionDirty = false;
setEditorInfo(doc); setEditorInfo(doc);
el.createPanel.close(); el.createPanel.close();
await openDocument({ doc, openSeq, skipAutoFonts: true, resetScroll: true }); await openDocument({ doc, openSeq, skipAutoFonts: true, resetScroll: true });
@@ -3558,6 +3556,7 @@ async function openOFD(file, remote = null) {
let bytes = downloaded ? downloaded.bytes : new Uint8Array(await file.arrayBuffer()); let bytes = downloaded ? downloaded.bytes : new Uint8Array(await file.arrayBuffer());
const name = downloaded ? downloaded.name : file.name; const name = downloaded ? downloaded.name : file.name;
let warnings = []; let warnings = [];
let convertedDoc;
if (openSeq !== state.openSeq) { if (openSeq !== state.openSeq) {
return; return;
} }
@@ -3576,11 +3575,12 @@ async function openOFD(file, remote = null) {
} }
if (openSeq !== state.openSeq) return; if (openSeq !== state.openSeq) return;
bytes = converted.bytes; bytes = converted.bytes;
convertedDoc = converted.doc;
warnings = JSON.parse(converted.warnings || "null") || []; warnings = JSON.parse(converted.warnings || "null") || [];
} }
await ensureWASM(); await ensureWASM();
if (openSeq !== state.openSeq) return; if (openSeq !== state.openSeq) return;
const doc = await openWithCredentials(await fontManager.files(fontManager.userFonts), openSeq, bytes); const doc = convertedDoc || await openWithCredentials(await fontManager.files(fontManager.userFonts), openSeq, bytes);
if (openSeq !== state.openSeq) return; if (openSeq !== state.openSeq) return;
state.ofdBytes = bytes; state.ofdBytes = bytes;
state.conversionWarnings = warnings; state.conversionWarnings = warnings;
@@ -3598,6 +3598,7 @@ async function openOFD(file, remote = null) {
state.editorViews.clear(); state.editorViews.clear();
state.fontRenderPending = false; state.fontRenderPending = false;
state.savedRevision = null; state.savedRevision = null;
state.encryptionDirty = false;
canvasEditor.clear(); canvasEditor.clear();
setDirty(false); setDirty(false);
el.createPanel.close(); el.createPanel.close();
@@ -4030,6 +4031,7 @@ function clearSecurityForms() {
if (sealPreview) finishSealPosition(false); if (sealPreview) finishSealPosition(false);
finishCredentials(null); finishCredentials(null);
clearEncryptionForm(); clearEncryptionForm();
el.encryptionPanel.close();
el.signKey.value = ""; el.signKey.value = "";
el.signKeyPassword.value = ""; el.signKeyPassword.value = "";
if (el.signPanel.open) el.signPanel.close(); if (el.signPanel.open) el.signPanel.close();
@@ -4088,8 +4090,13 @@ async function openWithCredentials(fonts, openSeq, data = state.ofdBytes, import
try { try {
while (openSeq === state.openSeq) { while (openSeq === state.openSeq) {
try { try {
return pdf ? await callWASM("ofdgoConvertPDF", data, fonts, credentials?.password || null) if (pdf) {
: importing ? await callWASM("ofdgoLoadImport", data.slice(), credentials) const converted = await callWASM("ofdgoConvertPDF", data, fonts, credentials?.password || null);
if (openSeq !== state.openSeq) return converted;
converted.doc = await callWASM("ofdgoOpen", converted.bytes, fonts, state.renderAnnotations, credentials);
return converted;
}
return importing ? await callWASM("ofdgoLoadImport", data.slice(), credentials)
: await callWASM("ofdgoOpen", data, fonts, state.renderAnnotations, credentials); : await callWASM("ofdgoOpen", data, fonts, state.renderAnnotations, credentials);
} catch (err) { } catch (err) {
if (openSeq !== state.openSeq || !(pdf ? err.code === "pdfPassword" : ["credentialsRequired", "invalidCredentials"].includes(err.code))) throw err; if (openSeq !== state.openSeq || !(pdf ? err.code === "pdfPassword" : ["credentialsRequired", "invalidCredentials"].includes(err.code))) throw err;
@@ -4426,14 +4433,90 @@ async function downloadResource(attachment, api, label) {
} }
} }
function encryptionMode() {
const info = state.doc?.encryption;
if (!info?.encrypted) return "none";
if (info.layers !== 1 || info.policyRequired) return "keep";
return info.method === "1.1.1" ? "password" : info.method === "1.1.2" ? "certificate" : "keep";
}
function openEncryptionPanel() {
if (!state.doc || document.body.hasAttribute("aria-busy")) return;
clearEncryptionForm();
el.encryptionType.value = encryptionMode();
el.encryptionKeep.hidden = el.encryptionType.value !== "keep";
updateEncryptionType();
el.encryptionPanel.showModal();
}
function updateEncryptionType() { function updateEncryptionType() {
const certificates = el.encryptionType.value === "certificate"; const mode = el.encryptionType.value;
el.encryptionUserRow.hidden = el.encryptionPasswordRow.hidden = el.encryptionConfirmRow.hidden = certificates; const existing = mode !== "none" && mode === encryptionMode() && !state.encryptionChanged;
const password = mode === "password" && !existing;
const certificates = mode === "certificate" && !existing;
el.encryptionCurrentRow.hidden = !existing;
el.encryptionCurrent.textContent = mode === "password" ? "已设置" : (state.doc?.encryption?.users || []).join("、") || "已加密";
el.encryptionChange.hidden = mode === "keep";
el.encryptionUserRow.hidden = el.encryptionPasswordRow.hidden = el.encryptionConfirmRow.hidden = !password;
el.encryptionCertificatesRow.hidden = !certificates; el.encryptionCertificatesRow.hidden = !certificates;
el.encryptionPassword.required = el.encryptionConfirm.required = state.exportEncrypted && !certificates; el.encryptionPassword.required = el.encryptionConfirm.required = password;
el.encryptionCertificates.required = state.exportEncrypted && certificates; el.encryptionCertificates.required = certificates;
el.encryptionUser.value = mode === "password" && encryptionMode() === mode ? state.doc.encryption.users?.[0] || "" : "";
el.encryptionPassword.value = el.encryptionConfirm.value = ""; el.encryptionPassword.value = el.encryptionConfirm.value = "";
el.encryptionCertificates.value = ""; el.encryptionCertificates.value = "";
el.encryptionStatus.textContent = mode === "keep" && state.doc?.encryption?.policyRequired ? "请重新设置加密方式" : "";
el.encryptionSubmit.disabled = mode === "keep" && Boolean(state.doc?.encryption?.policyRequired);
}
async function applyEncryption(event) {
event.preventDefault();
if (!state.doc || document.body.hasAttribute("aria-busy") || el.encryptionSubmit.disabled) return;
const mode = el.encryptionType.value;
if (mode === encryptionMode() && !state.encryptionChanged) {
el.encryptionPanel.close();
return;
}
if (mode === "password" && !el.encryptionPassword.value) {
el.encryptionStatus.textContent = "请输入口令";
el.encryptionPassword.focus();
return;
}
if (mode === "password" && el.encryptionPassword.value !== el.encryptionConfirm.value) {
el.encryptionStatus.textContent = "口令不一致";
el.encryptionConfirm.focus();
return;
}
if (mode === "certificate" && !el.encryptionCertificates.files?.length) {
el.encryptionStatus.textContent = "请选择接收者证书";
return;
}
const openSeq = state.openSeq;
const options = mode === "none" ? null : { password: new TextEncoder().encode(el.encryptionPassword.value), userName: el.encryptionUser.value.trim() };
el.encryptionForm.inert = true;
setBusy(true, "正在设置加密");
try {
if (mode === "certificate") options.recipients = await encryptionRecipients(Array.from(el.encryptionCertificates.files));
if (openSeq !== state.openSeq) return;
const doc = await callWASM("ofdgoSetEncryption", options);
if (openSeq !== state.openSeq) return;
if (!state.editorInfo) state.savedRevision = doc.revision;
state.encryptionDirty = true;
setEditorInfo(doc);
state.doc.encryption = doc.encryption;
state.ofdBytes = null;
el.encryptionPanel.close();
renderSecurity();
setStatus("加密设置已更新");
} catch (err) {
if (openSeq === state.openSeq) el.encryptionStatus.textContent = err.message;
} finally {
options?.password.fill(0);
if (openSeq === state.openSeq) {
el.encryptionPassword.value = el.encryptionConfirm.value = "";
el.encryptionForm.inert = false;
setBusy(false);
}
}
} }
async function encryptionRecipients(files) { async function encryptionRecipients(files) {
@@ -4448,11 +4531,12 @@ async function encryptionRecipients(files) {
} }
function clearEncryptionForm() { function clearEncryptionForm() {
el.encryptionForm.inert = false;
el.encryptionPassword.value = ""; el.encryptionPassword.value = "";
el.encryptionConfirm.value = ""; el.encryptionConfirm.value = "";
el.encryptionUser.value = ""; el.encryptionUser.value = "";
el.encryptionCertificates.value = ""; el.encryptionCertificates.value = "";
state.exportEncrypted = false; state.encryptionChanged = false;
} }
function renderSecurity() { function renderSecurity() {
@@ -4705,18 +4789,11 @@ async function verifyDocument(event) {
} }
} }
function openExportPanel(encrypted = false) { function openExportPanel() {
if (!state.doc || document.body.hasAttribute("aria-busy")) { if (!state.doc || document.body.hasAttribute("aria-busy")) {
return; return;
} }
el.exportForm.reset(); el.exportForm.reset();
clearEncryptionForm();
state.exportEncrypted = encrypted;
el.encryptionFields.hidden = !encrypted;
el.encryptionPassword.required = el.encryptionConfirm.required = encrypted;
updateEncryptionType();
el.exportTitle.textContent = encrypted ? "加密另存" : "导出文档";
el.exportSubmit.textContent = encrypted ? "保存" : "导出";
if (state.editing && state.pageSelection.size) { if (state.editing && state.pageSelection.size) {
el.exportSpecified.checked = true; el.exportSpecified.checked = true;
el.exportRange.value = selectedPageRange(); el.exportRange.value = selectedPageRange();
@@ -5097,14 +5174,14 @@ function compressionOptions(value) {
return preset > 2 ? { mode: 2, level: preset - 2 } : { mode: preset }; return preset > 2 ? { mode: 2, level: preset - 2 } : { mode: preset };
} }
async function exportFile(whole, indices = null, value = el.exportFormat.value, encryption = null) { async function exportFile(whole, indices = null, value = el.exportFormat.value) {
if (!state.doc || document.body.hasAttribute("aria-busy")) { if (!state.doc || document.body.hasAttribute("aria-busy")) {
return; return;
} }
const saving = value === "ofd"; const saving = value === "ofd";
const compression = compressionOptions(el.outputCompression.value); const compression = compressionOptions(el.outputCompression.value);
const outputOptions = compression.mode ? [compression] : []; const outputOptions = compression.mode ? [compression] : [];
if (saving && !state.editorInfo && !state.ofdBytes && !encryption) { if (saving && !state.editorInfo && !state.ofdBytes) {
return; return;
} }
if (!saving && state.doc.encryption?.encrypted && !window.confirm("此格式将输出明文,是否继续?")) return; if (!saving && state.doc.encryption?.encrypted && !window.confirm("此格式将输出明文,是否继续?")) return;
@@ -5140,15 +5217,10 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value,
openSeq = state.openSeq; openSeq = state.openSeq;
setBusy(true, "正在生成文件", null, status); setBusy(true, "正在生成文件", null, status);
} }
if (encryption?.recipientFiles) { const readingSave = saving && !state.editorInfo && indices === null && compression.mode === 0;
encryption.recipients = await encryptionRecipients(encryption.recipientFiles);
delete encryption.recipientFiles;
if (openSeq !== state.openSeq) return;
}
const readingSave = saving && !state.editorInfo && !encryption && indices === null && compression.mode === 0;
const result = readingSave const result = readingSave
? { label: "OFD", size: state.ofdBytes.byteLength, mime, blob: state.ofdBytes } ? { label: "OFD", size: state.ofdBytes.byteLength, mime, blob: state.ofdBytes }
: encryption ? await callWASM("ofdgoSaveEncrypted", indices, encryption, ...outputOptions, file) : saving ? await callWASM("ofdgoSaveDocument", indices, ...outputOptions, file) : whole : saving ? await callWASM("ofdgoSaveDocument", indices, ...outputOptions, file) : whole
? await callWASM("ofdgoExportDocument", format.value, dpi, indices, state.renderBackend, ...outputOptions, file) ? await callWASM("ofdgoExportDocument", format.value, dpi, indices, state.renderBackend, ...outputOptions, file)
: await callWASM("ofdgoExportPage", pageIndex, format.value, dpi, state.renderBackend, ...outputOptions, file); : await callWASM("ofdgoExportPage", pageIndex, format.value, dpi, state.renderBackend, ...outputOptions, file);
if (openSeq !== state.openSeq) { if (openSeq !== state.openSeq) {
@@ -5165,6 +5237,7 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value,
} }
if (saving && indices === null && state.editorInfo) { if (saving && indices === null && state.editorInfo) {
state.savedRevision = state.editorInfo.revision; state.savedRevision = state.editorInfo.revision;
state.encryptionDirty = false;
setDirty(false); setDirty(false);
} }
setStatus(`${result.label}已${saving ? "保存" : "导出"}(${formatBytes(result.size)})`); setStatus(`${result.label}已${saving ? "保存" : "导出"}(${formatBytes(result.size)})`);
@@ -8094,11 +8167,11 @@ async function callWASM(name, ...args) {
request.fontData = new Map(); request.fontData = new Map();
args.push([...request.localFonts.keys()]); args.push([...request.localFonts.keys()]);
} }
if (name === "ofdgoImportImages" || name === "ofdgoExportImages" || name === "ofdgoConvertPDF" || name === "ofdgoExportPage" || name === "ofdgoExportDocument" || name === "ofdgoExportAttachment" || name === "ofdgoSaveDocument" || name === "ofdgoSaveEncrypted" || name === "ofdgoSaveSigned" || name === "ofdgoImportPages") { if (name === "ofdgoImportImages" || name === "ofdgoExportImages" || name === "ofdgoConvertPDF" || name === "ofdgoExportPage" || name === "ofdgoExportDocument" || name === "ofdgoExportAttachment" || name === "ofdgoSaveDocument" || name === "ofdgoSaveSigned" || name === "ofdgoImportPages") {
state.exportRequestID = id; state.exportRequestID = id;
el.cancelExportButton.hidden = false; el.cancelExportButton.hidden = false;
el.cancelExportButton.disabled = false; el.cancelExportButton.disabled = false;
el.cancelExportButton.title = { ofdgoImportImages: "取消导入", ofdgoConvertPDF: "取消转换", ofdgoSaveDocument: "取消保存", ofdgoSaveEncrypted: "取消保存", ofdgoSaveSigned: "取消签署", ofdgoImportPages: "取消导入", ofdgoExportAttachment: "取消下载" }[name] || "取消导出"; el.cancelExportButton.title = { ofdgoImportImages: "取消导入", ofdgoConvertPDF: "取消转换", ofdgoSaveDocument: "取消保存", ofdgoSaveSigned: "取消签署", ofdgoImportPages: "取消导入", ofdgoExportAttachment: "取消下载" }[name] || "取消导出";
el.cancelExportButton.setAttribute("aria-label", el.cancelExportButton.title); el.cancelExportButton.setAttribute("aria-label", el.cancelExportButton.title);
} }
try { try {
+3 -3
View File
@@ -29,7 +29,7 @@ self.onmessage = ({ data }) => {
operations.get(data.id)?.abort(); operations.get(data.id)?.abort();
return; return;
} }
if (data.name === "ofdgoImportImages" || data.name === "ofdgoExportImages" || data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" || data.name === "ofdgoConvertPDF" || data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoSaveDocument" || data.name === "ofdgoSaveEncrypted" || data.name === "ofdgoSaveSigned" || data.name === "ofdgoImportPages") { if (data.name === "ofdgoImportImages" || data.name === "ofdgoExportImages" || data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" || data.name === "ofdgoConvertPDF" || data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoSaveDocument" || data.name === "ofdgoSaveSigned" || data.name === "ofdgoImportPages") {
operations.set(data.id, new AbortController()); operations.set(data.id, new AbortController());
} }
pending = pending.then(() => data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" ? handleBatchMessage(data) : handleMessage(data)); pending = pending.then(() => data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" ? handleBatchMessage(data) : handleMessage(data));
@@ -167,10 +167,10 @@ async function handleMessage({ id, name, args }) {
const signal = operations.get(id)?.signal; const signal = operations.get(id)?.signal;
const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages"; const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages";
const converting = name === "ofdgoConvertPDF"; const converting = name === "ofdgoConvertPDF";
const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveEncrypted" || name === "ofdgoSaveSigned"; const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveSigned";
const key = name === "ofdgoOpen" ? args[3]?.key : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.key : null; const key = name === "ofdgoOpen" ? args[3]?.key : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.key : null;
const keyPassword = name === "ofdgoOpen" ? args[3]?.keyPassword : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.keyPassword : null; const keyPassword = name === "ofdgoOpen" ? args[3]?.keyPassword : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.keyPassword : null;
const password = converting ? args[2] : name === "ofdgoOpen" ? args[3]?.password : name === "ofdgoSaveEncrypted" || name === "ofdgoLoadImport" ? args[1]?.password : null; const password = converting ? args[2] : name === "ofdgoSetEncryption" ? args[0]?.password : name === "ofdgoOpen" ? args[3]?.password : name === "ofdgoLoadImport" ? args[1]?.password : null;
try { try {
signal?.throwIfAborted(); signal?.throwIfAborted();
if (name === "ofdgoImportImages") { if (name === "ofdgoImportImages") {
+67 -28
View File
@@ -266,7 +266,7 @@ func RunWASM() {
registerCallback("ofdgoUndo", func([]js.Value) (any, error) { return restoreEditor(false) }) registerCallback("ofdgoUndo", func([]js.Value) (any, error) { return restoreEditor(false) })
registerCallback("ofdgoRedo", func([]js.Value) (any, error) { return restoreEditor(true) }) registerCallback("ofdgoRedo", func([]js.Value) (any, error) { return restoreEditor(true) })
registerAsyncCallback("ofdgoSaveDocument", saveDocument) registerAsyncCallback("ofdgoSaveDocument", saveDocument)
registerAsyncCallback("ofdgoSaveEncrypted", saveEncrypted) registerAsyncCallback("ofdgoSetEncryption", setEncryption)
registerAsyncCallback("ofdgoSaveSigned", saveSigned) registerAsyncCallback("ofdgoSaveSigned", saveSigned)
registerCallback("ofdgoPreviewSignatureSeal", previewSignatureSeal) registerCallback("ofdgoPreviewSignatureSeal", previewSignatureSeal)
registerCallback("ofdgoSignaturePlacement", signaturePlacement) registerCallback("ofdgoSignaturePlacement", signaturePlacement)
@@ -403,10 +403,21 @@ func convertPDFDocument(args []js.Value) (any, error) {
})) }))
} }
options.RendererOptions = []ofdgo.RendererOption{ofdgo.WithFontFS(fontSources...)} options.RendererOptions = []ofdgo.RendererOption{ofdgo.WithFontFS(fontSources...)}
report, err := ofdgo.ConvertPDF(context.Background(), bytes.NewReader(data), int64(len(data)), &output, options) editor, report, err := ofdgo.ImportPDF(context.Background(), bytes.NewReader(data), int64(len(data)), options)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if len(options.Password) != 0 {
if err := editor.SetEncryption(&ofdgo.EncryptionOptions{Password: options.Password}); err != nil {
return nil, err
}
}
editor.OnWriteProgress = func(stage string, completed, total int) error {
return options.OnProgress("write."+stage, completed, total)
}
if _, err := editor.WriteTo(&output); err != nil {
return nil, err
}
warnings, err := json.Marshal(report.Warnings) warnings, err := json.Marshal(report.Warnings)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -3555,6 +3566,7 @@ func editDocument(args []js.Value) (any, error) {
return nil, err return nil, err
} }
currentEditor = editor currentEditor = editor
currentSession.editor = editor
currentSession.editing = true currentSession.editing = true
copiedObjects = nil copiedObjects = nil
copiedStyle = nil copiedStyle = nil
@@ -3608,6 +3620,7 @@ func previewEditor(editor *ofdgo.Editor, annotations bool) (editorInfo, error) {
} }
editor.SetRenderBackends(session.Renderer.Backends()) editor.SetRenderBackends(session.Renderer.Backends())
currentSession = session currentSession = session
session.editor = editor
session.editing = true session.editing = true
if currentEditor != editor { if currentEditor != editor {
copiedObjects = nil copiedObjects = nil
@@ -4118,20 +4131,60 @@ func verifySignatures(args []js.Value) (any, error) {
return map[string]any{"signatures": infos, "signatureCount": len(infos), "signatureError": ""}, nil return map[string]any{"signatures": infos, "signatureCount": len(infos), "signatureError": ""}, nil
} }
// saveEncrypted 以独立快照加密另存,不修改当前文档的输出策略 // setEncryption 设置当前文档的保存策略,不修改页面或撤销历史
// 入参: args 加密选项、写出回调、进度回调和可选页面索引 // 入参: args 加密选项,null选择明文保存
// 返回: any 保存结果, error 错误信息 // 返回: any 编辑状态, error 错误信息
func saveEncrypted(args []js.Value) (any, error) { func setEncryption(args []js.Value) (any, error) {
if currentSession == nil || len(args) < 3 { if currentSession == nil || len(args) != 1 {
return nil, fmt.Errorf("missing encrypted save arguments") return nil, fmt.Errorf("missing encryption arguments")
} }
password, err := bytesFromJS(args[0].Get("password")) options, err := encryptionOptionsFromJS(args[0])
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer clear(password) if options != nil {
options := &ofdgo.EncryptionOptions{Password: password, UserName: args[0].Get("userName").String()} defer clear(options.Password)
if recipients := args[0].Get("recipients"); !recipients.IsUndefined() && !recipients.IsNull() { }
if currentEditor == nil {
if _, err := editDocument(nil); err != nil {
return nil, err
}
}
if err := currentEditor.SetEncryption(options); err != nil {
return nil, err
}
return editorSummary(), nil
}
// encryptionOptionsFromJS 读取加密选项并校验接收者证书
// 入参: value 加密选项,null选择明文保存
// 返回: *ofdgo.EncryptionOptions 加密选项, error 错误信息
func encryptionOptionsFromJS(value js.Value) (*ofdgo.EncryptionOptions, error) {
if value.IsNull() {
return nil, nil
}
if value.Type() != js.TypeObject {
return nil, ofdgo.ErrEncryptionPolicyRequired
}
var password []byte
if field := value.Get("password"); !field.IsNull() && !field.IsUndefined() {
var err error
password, err = bytesFromJS(field)
if err != nil {
return nil, err
}
}
options := &ofdgo.EncryptionOptions{Password: password}
success := false
defer func() {
if !success {
clear(password)
}
}()
if user := value.Get("userName"); user.Type() == js.TypeString {
options.UserName = user.String()
}
if recipients := value.Get("recipients"); !recipients.IsUndefined() && !recipients.IsNull() {
for i := 0; i < recipients.Length(); i++ { for i := 0; i < recipients.Length(); i++ {
item := recipients.Index(i) item := recipients.Index(i)
data, err := bytesFromJS(item.Get("certificate")) data, err := bytesFromJS(item.Get("certificate"))
@@ -4148,22 +4201,8 @@ func saveEncrypted(args []js.Value) (any, error) {
if len(password) == 0 && len(options.Recipients) == 0 { if len(password) == 0 && len(options.Recipients) == 0 {
return nil, ofdgo.ErrEncryptionPolicyRequired return nil, ofdgo.ErrEncryptionPolicyRequired
} }
reader := currentSession.Reader success = true
if currentEditor != nil { return options, nil
reader, err = currentEditor.Reader()
if err != nil {
return nil, err
}
defer reader.Close()
}
editor, err := reader.Editor()
if err != nil {
return nil, err
}
if err := editor.SetEncryption(options); err != nil {
return nil, err
}
return saveEditor(editor, args[1:])
} }
// previewSignatureSeal 渲染独立印章预览并读取所选页面区域,不接触私钥或修改文档 // previewSignatureSeal 渲染独立印章预览并读取所选页面区域,不接触私钥或修改文档
+11 -6
View File
@@ -62,6 +62,7 @@ type Session struct {
signatureError error signatureError error
signaturesRead bool signaturesRead bool
editing bool editing bool
editor *ofdgo.Editor
} }
// pageBoxInfo 按页索引缓存真实区域,valid区分未读取与零值区域 // pageBoxInfo 按页索引缓存真实区域,valid区分未读取与零值区域
@@ -251,10 +252,11 @@ type SignatureStampInfo struct {
// EncryptionInfo 仅向界面传递非敏感加密状态 // EncryptionInfo 仅向界面传递非敏感加密状态
type EncryptionInfo struct { type EncryptionInfo struct {
Encrypted bool `json:"encrypted"` Encrypted bool `json:"encrypted"`
Method string `json:"method,omitempty"` Method string `json:"method,omitempty"`
Users []string `json:"users,omitempty"` Users []string `json:"users,omitempty"`
Layers int `json:"layers,omitempty"` Layers int `json:"layers,omitempty"`
PolicyRequired bool `json:"policyRequired,omitempty"`
} }
// Open 打开浏览器内存中的OFD文档 // Open 打开浏览器内存中的OFD文档
@@ -768,11 +770,14 @@ func exportFormat(value string) (ExportFormat, bool) {
return ExportFormat{}, false return ExportFormat{}, false
} }
// encryptionInfo 获取当前文档的加密来源,不包含凭据 // encryptionInfo 获取当前文档的保存策略,不包含凭据
// 返回: EncryptionInfo 加密状态 // 返回: EncryptionInfo 加密状态
func (s *Session) encryptionInfo() EncryptionInfo { func (s *Session) encryptionInfo() EncryptionInfo {
info := s.Reader.Encryption() info := s.Reader.Encryption()
return EncryptionInfo{Encrypted: info.Encrypted, Method: info.Method, Users: info.Users, Layers: info.Layers} if s.editor != nil {
info = s.editor.Encryption()
}
return EncryptionInfo{Encrypted: info.Encrypted, Method: info.Method, Users: info.Users, Layers: info.Layers, PolicyRequired: info.PolicyRequired}
} }
// signatureInfos 获取签名验证信息 // signatureInfos 获取签名验证信息
+15 -6
View File
@@ -76,11 +76,13 @@ type EncryptionOptions struct {
} }
// EncryptionInfo 不含敏感凭据的文档加密信息 // EncryptionInfo 不含敏感凭据的文档加密信息
// PolicyRequired表示无法继承原策略,保存前需显式设置加密方式
type EncryptionInfo struct { type EncryptionInfo struct {
Encrypted bool Encrypted bool
Method string Method string
Users []string Users []string
Layers int Layers int
PolicyRequired bool
} }
// encryptionState 保存解密来源与可继承的输出策略 // encryptionState 保存解密来源与可继承的输出策略
@@ -113,10 +115,17 @@ func WithCryptoProvider(provider CryptoProvider) ReaderOption {
// Encryption 获取文档加密状态,不暴露口令或私钥 // Encryption 获取文档加密状态,不暴露口令或私钥
// 返回: EncryptionInfo 加密状态 // 返回: EncryptionInfo 加密状态
func (r *Reader) Encryption() EncryptionInfo { func (r *Reader) Encryption() EncryptionInfo {
if r.encryption == nil { return r.encryption.summary()
}
// summary 复制不含凭据的加密状态
// 返回: EncryptionInfo 加密状态
func (s *encryptionState) summary() EncryptionInfo {
if s == nil {
return EncryptionInfo{} return EncryptionInfo{}
} }
info := r.encryption.info info := s.info
info.PolicyRequired = s.options == nil
info.Users = append([]string(nil), info.Users...) info.Users = append([]string(nil), info.Users...)
return info return info
} }
+6
View File
@@ -19,6 +19,12 @@ import (
"io" "io"
) )
// Encryption 获取当前保存策略,不暴露口令或私钥
// 返回: EncryptionInfo 加密状态
func (e *Editor) Encryption() EncryptionInfo {
return e.encryption.summary()
}
// SetEncryption 设置后续保存的加密策略,nil显式选择明文保存 // SetEncryption 设置后续保存的加密策略,nil显式选择明文保存
// 打开单用户口令或证书加密文档时默认继承原策略;多人口令和多层加密需显式选择输出策略 // 打开单用户口令或证书加密文档时默认继承原策略;多人口令和多层加密需显式选择输出策略
// 新的加密密钥与初始向量在每次保存时独立生成,设置不改变文档内容或撤销历史 // 新的加密密钥与初始向量在每次保存时独立生成,设置不改变文档内容或撤销历史