feat(签章能力): 优化签章画布定位交互
build.yaml / build (push) Successful in 45s

This commit is contained in:
xiaoqidun committed 2026-09-27 23:41:18 +08:00
1 parent 31e67b2721
commit 5842522edf
12 files changed
+465 -42

No files matched your search

+6
View File
@@ -438,6 +438,7 @@
<div class="form-row"><label for="signIntermediates">中间证书</label><input id="signIntermediates" type="file" accept=".pem,.cer,.crt,.der" multiple></div> <div class="form-row"><label for="signIntermediates">中间证书</label><input id="signIntermediates" type="file" accept=".pem,.cer,.crt,.der" multiple></div>
<div class="form-row"><label for="signSeal">电子印章</label><input id="signSeal" type="file" accept=".esl,.ses,.der" aria-label="SES电子印章"></div> <div class="form-row"><label for="signSeal">电子印章</label><input id="signSeal" type="file" accept=".esl,.ses,.der" aria-label="SES电子印章"></div>
<div id="signPlacementFields" hidden> <div id="signPlacementFields" hidden>
<div class="form-row"><span>预览</span><div class="seal-position-options"><button id="signPick" class="button" type="button">定位</button><label><input id="signKeepRatio" type="checkbox" checked>等比</label></div></div>
<div class="form-row"><label for="signPlacement">位置</label><select id="signPlacement"><option value="normal">页面</option><option value="seam">骑缝</option></select></div> <div class="form-row"><label for="signPlacement">位置</label><select id="signPlacement"><option value="normal">页面</option><option value="seam">骑缝</option></select></div>
<div class="form-row"><label for="signPages">页码</label><input id="signPages" type="text" autocomplete="off" spellcheck="false"></div> <div class="form-row"><label for="signPages">页码</label><input id="signPages" type="text" autocomplete="off" spellcheck="false"></div>
<div class="form-row"><label for="signX">横坐标</label><div class="form-unit"><input id="signX" type="number" min="0" step="any" value="10" aria-label="横坐标(毫米)"><span>mm</span></div></div> <div class="form-row"><label for="signX">横坐标</label><div class="form-unit"><input id="signX" type="number" min="0" step="any" value="10" aria-label="横坐标(毫米)"><span>mm</span></div></div>
@@ -450,6 +451,11 @@
<div class="form-actions"><div id="signStatus" class="form-status" role="status" aria-live="polite"></div><button id="signCancel" class="button" type="button">取消</button><button class="button" type="submit" title="签署另存">签署</button></div> <div class="form-actions"><div id="signStatus" class="form-status" role="status" aria-live="polite"></div><button id="signCancel" class="button" type="button">取消</button><button class="button" type="submit" title="签署另存">签署</button></div>
</form> </form>
</dialog> </dialog>
<div hidden>
<div id="signPositionLayer" class="seal-position-layer" aria-hidden="true">
<div id="signPositionStamp" class="seal-position-stamp" hidden><img id="signPositionImage" alt="" draggable="false"></div>
</div>
</div>
<dialog id="verifyPanel" class="form-dialog security-dialog" aria-labelledby="verifyTitle"> <dialog id="verifyPanel" class="form-dialog security-dialog" aria-labelledby="verifyTitle">
<form id="verifyForm"> <form id="verifyForm">
<h2 id="verifyTitle" class="form-title">验证签名</h2> <h2 id="verifyTitle" class="form-title">验证签名</h2>
+37
View File
@@ -1041,6 +1041,43 @@ button:not(:disabled):hover {
height: 100%; height: 100%;
} }
.seal-position-options,
.seal-position-options label {
display: flex;
align-items: center;
gap: 12px;
}
.seal-picking .page-surface {
cursor: crosshair;
touch-action: none;
}
.seal-picking .edit-layer {
visibility: hidden;
}
.seal-position-layer {
position: absolute;
inset: 0;
z-index: 10;
pointer-events: none;
overflow: hidden;
}
.seal-position-stamp {
position: absolute;
overflow: hidden;
outline: 1px solid var(--accent);
}
.seal-position-stamp img {
position: absolute;
max-width: none;
height: 100%;
pointer-events: none;
}
.security-dialog { .security-dialog {
width: 360px; width: 360px;
} }
+143 -7
View File
@@ -1129,8 +1129,15 @@ el.signCancel.addEventListener("click", () => state.signing ? cancelSigning() :
el.signPanel.addEventListener("cancel", event => { el.signPanel.addEventListener("cancel", event => {
if (state.signing) { event.preventDefault(); cancelSigning(); } if (state.signing) { event.preventDefault(); cancelSigning(); }
}); });
el.signPanel.addEventListener("close", () => { if (!el.signPanel.open) el.signForm.reset(); }); el.signPanel.addEventListener("close", () => { if (!el.signPanel.open && !sealPreview) el.signForm.reset(); });
el.signForm.addEventListener("submit", signDocument); el.signForm.addEventListener("submit", signDocument);
const sealPosition = Object.fromEntries(["Layer", "Stamp", "Image"].map(name => [name, document.querySelector(`#signPosition${name}`)]));
let sealPreview = null;
document.querySelector("#signPick").addEventListener("click", openSealPosition);
el.viewerPanel.addEventListener("pointerdown", beginSealDrag, true);
el.viewerPanel.addEventListener("pointermove", moveSealDrag, true);
el.viewerPanel.addEventListener("pointerup", confirmSealPosition, true);
el.viewerPanel.addEventListener("pointercancel", () => { if (sealPreview) finishSealPosition(); }, true);
el.verifyButton.addEventListener("click", () => { el.verifyButton.addEventListener("click", () => {
if (!state.doc || document.body.hasAttribute("aria-busy")) return; if (!state.doc || document.body.hasAttribute("aria-busy")) return;
el.verifyForm.reset(); el.verifyForm.reset();
@@ -1343,6 +1350,10 @@ updateSidebarState();
boot(); boot();
function handleKeyDown(event) { function handleKeyDown(event) {
if (sealPreview) {
if (event.key === "Escape") { event.preventDefault(); finishSealPosition(); }
return;
}
if (event.defaultPrevented || event.isComposing || event.altKey) { if (event.defaultPrevented || event.isComposing || event.altKey) {
return; return;
} }
@@ -1457,7 +1468,7 @@ function handleKeyDown(event) {
} }
function formDialogOpen() { function formDialogOpen() {
return 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 || 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;
} }
@@ -3831,6 +3842,7 @@ function updateFontPermissionHint() {
} }
function clearSecurityForms() { function clearSecurityForms() {
if (sealPreview) finishSealPosition(false);
finishCredentials(null); finishCredentials(null);
clearEncryptionForm(); clearEncryptionForm();
el.signKey.value = ""; el.signKey.value = "";
@@ -4248,11 +4260,134 @@ function cancelSigning() {
cancelExport(); cancelExport();
} }
function finishSealPosition(returnToForm = true) {
const preview = sealPreview;
if (!preview) return;
sealPreview = null;
if (preview.pointer != null && el.viewerPanel.hasPointerCapture(preview.pointer)) el.viewerPanel.releasePointerCapture(preview.pointer);
URL.revokeObjectURL(preview.url);
sealPosition.Layer.remove();
sealPosition.Image.removeAttribute("src");
el.viewerPanel.classList.remove("seal-picking");
canvasEditor.enabled = preview.enabled;
if (preview.sidebar && COMPACT_LAYOUT.matches) {
Object.assign(state, preview.sidebar);
updateSidebarState();
}
setBusy(document.body.hasAttribute("aria-busy"));
if (returnToForm && preview.sequence === state.openSeq && !el.signPanel.open) el.signPanel.showModal();
if (preview.sequence === state.openSeq) setStatus(preview.status);
}
async function openSealPosition() {
const file = el.signSeal.files?.[0];
if (!file || !state.doc || document.body.hasAttribute("aria-busy")) return;
const sequence = state.openSeq;
setBusy(true);
try {
const range = el.signPages.value.trim(), mode = el.signPlacement.value;
const selected = await callWASM("ofdgoParsePageRange", range);
if (mode === "seam" && selected.length < 2) throw new Error("骑缝签章至少选择两页");
const result = await callWASM("ofdgoPreviewSignatureSeal", new Uint8Array(await file.arrayBuffer()), `1-${state.doc.pageCount}`);
if (sequence !== state.openSeq || !el.signPanel.open || file !== el.signSeal.files?.[0]) return;
sealPreview = {pages:result.pages, selected, range, mode, ratio:document.querySelector("#signKeepRatio").checked ? result.width / result.height : 0, sequence, enabled:canvasEditor.enabled, status:el.statusText.textContent, url:URL.createObjectURL(new Blob([result.svg], {type:"image/svg+xml"}))};
sealPosition.Image.src = sealPreview.url;
await sealPosition.Image.decode();
if (sequence !== state.openSeq || !el.signPanel.open) { finishSealPosition(false); return; }
canvasEditor.enabled = false;
if (COMPACT_LAYOUT.matches) {
sealPreview.sidebar = {showPages:state.showPages, showMeta:state.showMeta};
state.showPages = state.showMeta = false;
updateSidebarState();
}
el.viewerPanel.classList.add("seal-picking");
el.signPanel.close();
el.signStatus.textContent = "";
setStatus("拖动选取签章区域,Esc 取消");
el.viewerPanel.focus({preventScroll:true});
} catch (error) { finishSealPosition(); el.signStatus.textContent = error.message; }
finally { if (sequence === state.openSeq) setBusy(false); }
}
function sealPointer(event) {
const preview = sealPreview;
const point = pagePoint(event.clientX, event.clientY, preview.surface.getBoundingClientRect(), preview.page, state.rotation);
return {x:preview.page.x + point.x, y:preview.page.y + point.y};
}
function beginSealDrag(event) {
const preview = sealPreview;
if (!preview || preview.confirming || event.button !== 0) return;
const surface = event.target.closest(".page-surface");
if (!surface) return;
event.preventDefault();
event.stopImmediatePropagation();
const index = Number(surface.closest(".page-shell").dataset.pageIndex);
if ((preview.mode === "seam" || preview.selected.length > 1) && !preview.selected.includes(index)) {
setStatus("请在所选页码范围内定位");
return;
}
preview.page = preview.pages[index];
preview.surface = surface;
preview.start = sealPointer(event);
preview.pointer = event.pointerId;
preview.box = null;
sealPosition.Stamp.hidden = true;
surface.append(sealPosition.Layer);
el.viewerPanel.setPointerCapture(event.pointerId);
}
function moveSealDrag(event) {
const preview = sealPreview;
if (!preview?.start || preview.pointer !== event.pointerId) return;
event.preventDefault();
event.stopImmediatePropagation();
const point = sealPointer(event), start = preview.start, page = preview.page;
const dx = point.x - start.x, dy = point.y - start.y;
let width = Math.abs(dx), height = Math.abs(dy);
const count = preview.mode === "seam" ? preview.selected.length : 1;
if (preview.ratio) {
width = Math.min(width, height * preview.ratio / count);
height = width * count / preview.ratio;
}
const x = dx < 0 ? start.x - width : start.x, y = dy < 0 ? start.y - height : start.y;
preview.box = {x:preview.mode === "seam" ? page.x + page.width - x - width : x, y, width:width * count, height};
sealPosition.Stamp.hidden = false;
Object.assign(sealPosition.Stamp.style, {left:`${(x-page.x)/page.width*100}%`, top:`${(y-page.y)/page.height*100}%`, width:`${width/page.width*100}%`, height:`${height/page.height*100}%`});
Object.assign(sealPosition.Image.style, {width:`${count*100}%`, left:`${preview.mode === "seam" ? -preview.selected.indexOf(page.index)*100 : 0}%`});
}
async function confirmSealPosition(event) {
const preview = sealPreview;
if (!preview?.start || preview.pointer !== event.pointerId || preview.confirming) return;
moveSealDrag(event);
preview.start = null;
el.viewerPanel.releasePointerCapture(event.pointerId);
const box = preview.box;
if (!box || box.width < 0.01 || box.height < 0.01) { sealPosition.Layer.remove(); return; }
preview.confirming = true;
const range = preview.mode === "normal" && preview.selected.length === 1 ? String(preview.page.index + 1) : preview.range;
try {
await callWASM("ofdgoSignaturePlacement", {...box, pages:range, placement:preview.mode});
if (sealPreview !== preview || preview.sequence !== state.openSeq) return;
el.signPages.value = range;
for (const [name, key] of [["X", "x"], ["Y", "y"], ["Width", "width"], ["Height", "height"]]) el[`sign${name}`].value = String(box[key]);
finishSealPosition();
} catch (error) {
if (sealPreview === preview) { finishSealPosition(); el.signStatus.textContent = error.message; }
}
}
function updateSignPlacement() { function updateSignPlacement() {
const stamp = Boolean(el.signSeal.files?.length); const stamp = Boolean(el.signSeal.files?.length);
el.signPlacementFields.hidden = !stamp; el.signPlacementFields.hidden = !stamp;
el.signPages.required = stamp; el.signPlacement.disabled = !stamp;
el.signX.disabled = !stamp || el.signPlacement.value === "seam"; for (const input of [el.signPages, el.signX, el.signY, el.signWidth, el.signHeight]) {
input.disabled = !stamp;
input.required = stamp;
}
document.querySelector('label[for="signX"]').textContent = el.signPlacement.value === "seam" ? "右侧内缩" : "横坐标";
el.signX.setAttribute("aria-label", el.signPlacement.value === "seam" ? "右侧内缩(毫米)" : "横坐标(毫米)");
} }
async function signDocument(event) { async function signDocument(event) {
@@ -5637,6 +5772,7 @@ async function pasteEditorContent(event) {
} }
function copySelection(event) { function copySelection(event) {
if (sealPreview) return;
if (event.target.closest?.("input, textarea, [contenteditable]")) { if (event.target.closest?.("input, textarea, [contenteditable]")) {
return; return;
} }
@@ -7580,7 +7716,7 @@ function updateEditorTools() {
const moveDisabled = selectionDisabled || selectedPages.some(index => !pageCan("move", index)); const moveDisabled = selectionDisabled || selectedPages.some(index => !pageCan("move", index));
el.movePagePrevButton.disabled = moveDisabled || selectedPages[0] === 0; el.movePagePrevButton.disabled = moveDisabled || selectedPages[0] === 0;
el.movePageNextButton.disabled = moveDisabled || selectedPages.at(-1) === state.doc?.pageCount - 1; el.movePageNextButton.disabled = moveDisabled || selectedPages.at(-1) === state.doc?.pageCount - 1;
const enabled = state.editing && state.selectObjects && !state.panMode; const enabled = state.editing && state.selectObjects && !state.panMode && !sealPreview;
canvasEditor.setEnabled(enabled); canvasEditor.setEnabled(enabled);
updateObjectControls(canvasEditor.selected); updateObjectControls(canvasEditor.selected);
el.undoButton.disabled = !state.editorInfo?.canUndo || !state.ready || state.exporting; el.undoButton.disabled = !state.editorInfo?.canUndo || !state.ready || state.exporting;
@@ -7728,7 +7864,7 @@ function setBusy(busy, text = "", percent = 0, status = "") {
document.body.toggleAttribute("aria-busy", busy); document.body.toggleAttribute("aria-busy", busy);
renderSecurity(); renderSecurity();
updateDisplayControls(); updateDisplayControls();
el.editButton.disabled = busy || !state.doc || !state.ready || state.exporting; el.editButton.disabled = busy || Boolean(sealPreview) || !state.doc || !state.ready || state.exporting;
el.createForm.inert = busy; el.createForm.inert = busy;
el.insertForm.inert = busy; el.insertForm.inert = busy;
el.pageForm.inert = busy; el.pageForm.inert = busy;
@@ -7746,7 +7882,7 @@ function setBusy(busy, text = "", percent = 0, status = "") {
el.outlineList.inert = busy; el.outlineList.inert = busy;
el.navigationTabs.inert = busy; el.navigationTabs.inert = busy;
el.pageList.inert = busy; el.pageList.inert = busy;
el.editorTools.inert = busy; el.editorTools.inert = busy || Boolean(sealPreview);
el.fontList.inert = busy; el.fontList.inert = busy;
if (!busy) { if (!busy) {
el.progressPanel.hidden = true; el.progressPanel.hidden = true;
+1 -1
View File
@@ -9,7 +9,7 @@ require (
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c
github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6 github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6
github.com/xiaoqidun/jbig2 v1.1.1 github.com/xiaoqidun/jbig2 v1.1.1
github.com/xiaoqidun/pdfgo v0.0.0-20260927142759-12ed20917f60 github.com/xiaoqidun/pdfgo v0.0.0-20260927150509-ae8390858589
golang.org/x/crypto v0.57.0 golang.org/x/crypto v0.57.0
golang.org/x/image v0.46.0 golang.org/x/image v0.46.0
golang.org/x/text v0.42.0 golang.org/x/text v0.42.0
+2 -2
View File
@@ -62,8 +62,8 @@ github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/xiaoqidun/jbig2 v1.1.1 h1:SjliS9ZO0e9HX5oYUfBuXrfFsXhWricrSz+XY4zORDI= github.com/xiaoqidun/jbig2 v1.1.1 h1:SjliS9ZO0e9HX5oYUfBuXrfFsXhWricrSz+XY4zORDI=
github.com/xiaoqidun/jbig2 v1.1.1/go.mod h1:8QonVWtENmh2kc6l+leqeGl7psb79DWlHDkCpdChjfQ= github.com/xiaoqidun/jbig2 v1.1.1/go.mod h1:8QonVWtENmh2kc6l+leqeGl7psb79DWlHDkCpdChjfQ=
github.com/xiaoqidun/pdfgo v0.0.0-20260927142759-12ed20917f60 h1:0JC6RK3DMb11RIVoaSSlprHo9oAZb+YyfUThEsTs0Bk= github.com/xiaoqidun/pdfgo v0.0.0-20260927150509-ae8390858589 h1:dHH5wnrIyOKRpx3Dw4IiMgMEPMrNFdatfkoMKpX3Khk=
github.com/xiaoqidun/pdfgo v0.0.0-20260927142759-12ed20917f60/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ= github.com/xiaoqidun/pdfgo v0.0.0-20260927150509-ae8390858589/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
+102 -21
View File
@@ -286,6 +286,8 @@ func RunWASM() {
registerAsyncCallback("ofdgoSaveDocument", saveDocument) registerAsyncCallback("ofdgoSaveDocument", saveDocument)
registerAsyncCallback("ofdgoSaveEncrypted", saveEncrypted) registerAsyncCallback("ofdgoSaveEncrypted", saveEncrypted)
registerAsyncCallback("ofdgoSaveSigned", saveSigned) registerAsyncCallback("ofdgoSaveSigned", saveSigned)
registerCallback("ofdgoPreviewSignatureSeal", previewSignatureSeal)
registerCallback("ofdgoSignaturePlacement", signaturePlacement)
select {} select {}
} }
@@ -4148,6 +4150,105 @@ func saveEncrypted(args []js.Value) (any, error) {
return saveEditor(editor, args[1:]) return saveEditor(editor, args[1:])
} }
// previewSignatureSeal 渲染独立印章预览并读取所选页面区域,不接触私钥或修改文档
// 入参: args 印章数据和页码范围
// 返回: any 印章图像、声明尺寸和页面区域, error 错误信息
func previewSignatureSeal(args []js.Value) (any, error) {
if currentSession == nil || len(args) < 2 {
return nil, fmt.Errorf("missing seal preview arguments")
}
data, err := bytesFromJS(args[0])
if err != nil {
return nil, err
}
seal, err := ofdgo.ParseSignatureSeal(data)
if err != nil {
return nil, err
}
indices, err := ofdgo.ParsePageRange(args[1].String(), len(currentSession.doc.Pages.Page))
if err != nil {
return nil, err
}
pages := make([]map[string]any, 0, len(indices))
for _, index := range indices {
area, err := currentSession.Reader.PageArea(currentSession.doc.Pages.Page[index])
if err != nil {
return nil, err
}
box, err := ofdgo.ParseBox(area.PhysicalBox)
if err != nil {
return nil, err
}
pages = append(pages, map[string]any{"index": index, "x": box.X, "y": box.Y, "width": box.W, "height": box.H})
}
editor := ofdgo.NewEditor()
if _, err := editor.AddPage(seal.Width, seal.Height); err != nil {
return nil, err
}
reader, err := editor.Reader()
if err != nil {
return nil, err
}
defer reader.Close()
doc, err := reader.Doc()
if err != nil {
return nil, err
}
reader.Stamps = map[string][]ofdgo.Stamp{doc.Pages.Page[0].ID: {{Box: ofdgo.Box{W: seal.Width, H: seal.Height}, Type: seal.Type, Data: seal.Data}}}
renderer := ofdgo.NewRenderer(reader, ofdgo.WithRenderBackends(currentSession.Renderer.Backends()))
renderer.TransparentBackground = true
if currentSession.fontFS != nil {
renderer.SetFontFS(currentSession.fontFS)
}
page, err := reader.PageContentByIndex(0)
if err != nil {
return nil, err
}
var svg bytes.Buffer
if err := renderer.RenderToSVG(page, &svg); err != nil {
return nil, err
}
return map[string]any{"svg": svg.String(), "width": seal.Width, "height": seal.Height, "pages": pages}, nil
}
// signatureStampsFromJS 复用库层坐标校验生成页面或骑缝印章位置
// 入参: input 页面和毫米坐标参数
// 返回: []ofdgo.SignatureStamp 印章位置, error 校验错误
func signatureStampsFromJS(input js.Value) ([]ofdgo.SignatureStamp, error) {
count, err := currentSession.Reader.PageCount()
if err != nil {
return nil, err
}
indices, err := ofdgo.ParsePageRange(input.Get("pages").String(), count)
if err != nil {
return nil, err
}
pages := make([]int, len(indices))
for i, index := range indices {
pages[i] = index + 1
}
box := ofdgo.Box{X: input.Get("x").Float(), Y: input.Get("y").Float(), W: input.Get("width").Float(), H: input.Get("height").Float()}
switch input.Get("placement").String() {
case "normal":
return currentSession.Reader.SignaturePageStamps(pages, box)
case "seam":
return currentSession.Reader.SignatureSeamStamps(pages, box)
default:
return nil, fmt.Errorf("invalid stamp placement")
}
}
// signaturePlacement 校验预览确认的印章范围,不生成签名
// 入参: args 页面和毫米坐标参数
// 返回: any 校验结果, error 错误信息
func signaturePlacement(args []js.Value) (any, error) {
if currentSession == nil || len(args) == 0 {
return nil, fmt.Errorf("missing stamp placement arguments")
}
_, err := signatureStampsFromJS(args[0])
return successResult(nil), err
}
// saveSigned 调用库层最终包签署并在回验成功后交付浏览器 // saveSigned 调用库层最终包签署并在回验成功后交付浏览器
// 入参: args 本机证书私钥、信任根、印章位置、写出回调及进度回调 // 入参: args 本机证书私钥、信任根、印章位置、写出回调及进度回调
// 返回: any 保存结果, error 错误信息 // 返回: any 保存结果, error 错误信息
@@ -4200,30 +4301,10 @@ func saveSigned(args []js.Value) (any, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
count, err := currentSession.Reader.PageCount() options.Stamps, err = signatureStampsFromJS(input)
if err != nil { if err != nil {
return nil, err return nil, err
} }
indices, err := ofdgo.ParsePageRange(input.Get("pages").String(), count)
if err != nil {
return nil, err
}
box := ofdgo.Box{X: input.Get("x").Float(), Y: input.Get("y").Float(), W: input.Get("width").Float(), H: input.Get("height").Float()}
if input.Get("placement").String() == "seam" {
pages := make([]int, len(indices))
for i, index := range indices {
pages[i] = index + 1
}
options.Stamps, err = currentSession.Reader.SignatureSeamStamps(pages, box)
if err != nil {
return nil, err
}
} else {
for _, index := range indices {
options.Stamps = append(options.Stamps, ofdgo.SignatureStamp{PageRef: currentSession.doc.Pages.Page[index].ID,
Boundary: fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H)})
}
}
} }
progress := editorOperationProgress(args[2]) progress := editorOperationProgress(args[2])
if err := progress("sign", 0, 1); err != nil { if err := progress("sign", 0, 1); err != nil {
+21 -9
View File
@@ -39,18 +39,30 @@ type PageAnnot struct {
// Annotation 页面注释 // Annotation 页面注释
type Annotation struct { type Annotation struct {
ID string `xml:"ID,attr"` ID string `xml:"ID,attr"`
Type string `xml:"Type,attr"` Type string `xml:"Type,attr"`
Subtype string `xml:"Subtype,attr"` Subtype string `xml:"Subtype,attr"`
Creator string `xml:"Creator,attr"` Creator string `xml:"Creator,attr"`
LastModDate string `xml:"LastModDate,attr"` LastModDate string `xml:"LastModDate,attr"`
Visible *bool `xml:"Visible,attr"` Visible *bool `xml:"Visible,attr"`
NoZoom bool `xml:"NoZoom,attr"` NoZoom bool `xml:"NoZoom,attr"`
NoRotate bool `xml:"NoRotate,attr"` NoRotate bool `xml:"NoRotate,attr"`
Remark string `xml:"Remark"` Remark string `xml:"Remark"`
Parameters *AnnotationParameters `xml:"Parameters,omitempty"`
Appearance Appearance Appearance Appearance
} }
// AnnotationParameters 保存注释参数集合
type AnnotationParameters struct {
Parameter []AnnotationParameter `xml:"Parameter"`
}
// AnnotationParameter 保存标准注释参数的名称和值
type AnnotationParameter struct {
Name string `xml:"Name,attr"`
Value string `xml:",chardata"`
}
// AnnotationInfo 注释信息,Page从1开始,位置和尺寸使用页面毫米坐标 // AnnotationInfo 注释信息,Page从1开始,位置和尺寸使用页面毫米坐标
type AnnotationInfo struct { type AnnotationInfo struct {
ID string `json:"id"` ID string `json:"id"`
+22
View File
@@ -96,6 +96,28 @@ func (e *Editor) AddAnnotation(index int, annotation Annotation) (string, error)
if annotation.Remark != "" { if annotation.Remark != "" {
content = editorXMLText("Remark", annotation.Remark) content = editorXMLText("Remark", annotation.Remark)
} }
if annotation.Parameters != nil && len(annotation.Parameters.Parameter) != 0 {
var parameters []byte
for _, parameter := range annotation.Parameters.Parameter {
if parameter.Name == "" {
return fmt.Errorf("empty annotation parameter name")
}
var value bytes.Buffer
if err := xml.EscapeText(&value, []byte(parameter.Value)); err != nil {
return err
}
data, err := editorXMLContainer("Parameter", ofdAttrs{{Name: xml.Name{Local: "Name"}, Value: parameter.Name}}, value.Bytes())
if err != nil {
return err
}
parameters = append(parameters, bytes.TrimPrefix(data, []byte(xml.Header))...)
}
data, err := editorXMLContainer("Parameters", nil, parameters)
if err != nil {
return err
}
content = append(content, bytes.TrimPrefix(data, []byte(xml.Header))...)
}
content = append(content, bytes.TrimPrefix(appearance, []byte(xml.Header))...) content = append(content, bytes.TrimPrefix(appearance, []byte(xml.Header))...)
data, err := editorXMLContainer("Annot", attrs, content) data, err := editorXMLContainer("Annot", attrs, content)
if err != nil { if err != nil {
+15 -1
View File
@@ -112,6 +112,20 @@ func (p *pdfImporter) appearanceAnnotation(ctx context.Context, page *pdfgo.Page
p.report.Warnings = append(p.report.Warnings, pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF darken stamp appearance flattened over page content"}) p.report.Warnings = append(p.report.Warnings, pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF darken stamp appearance flattened over page content"})
} }
converted := Annotation{Type: pdfAnnotationType(annotation.Subtype), Subtype: string(annotation.Subtype), Appearance: Appearance{Boundary: pdfBoundary(box), Objects: stamp.objects}} converted := Annotation{Type: pdfAnnotationType(annotation.Subtype), Subtype: string(annotation.Subtype), Appearance: Appearance{Boundary: pdfBoundary(box), Objects: stamp.objects}}
popupObject := annotation.Dictionary["Popup"]
if annotation.Subtype == "Popup" {
popupObject = annotation.Dictionary
}
if popupObject != nil {
popup, err := p.reader.ReadPopupAnnotation(popupObject)
if err != nil {
return err
}
converted.Parameters = &AnnotationParameters{Parameter: []AnnotationParameter{
{Name: "PDF.Popup.Open", Value: strconv.FormatBool(popup.Open)},
{Name: "PDF.Popup.Rect", Value: fmt.Sprintf("%g %g %g %g", popup.Rect.XMin, popup.Rect.YMin, popup.Rect.XMax, popup.Rect.YMax)},
}}
}
if len(actions) != 0 { if len(actions) != 0 {
region, err := NewShape(ShapeRectangle, Box{W: box.W, H: box.H}) region, err := NewShape(ShapeRectangle, Box{W: box.W, H: box.H})
if err != nil { if err != nil {
@@ -220,7 +234,7 @@ func pdfAnnotationType(subtype pdfgo.Name) string {
return "Watermark" return "Watermark"
case "Highlight", "Underline", "Squiggly", "StrikeOut": case "Highlight", "Underline", "Squiggly", "StrikeOut":
return "Highlight" return "Highlight"
case "Text", "FreeText", "Line", "Square", "Circle", "Polygon", "PolyLine", "Caret", "Ink", "FileAttachment", "Sound", "Movie", "Screen", "Link": case "Text", "FreeText", "Line", "Square", "Circle", "Polygon", "PolyLine", "Caret", "Ink", "FileAttachment", "Sound", "Movie", "Screen", "Link", "Popup":
return "Path" return "Path"
} }
return "" return ""
+53 -1
View File
@@ -18,6 +18,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"maps"
"github.com/xiaoqidun/pdfgo" "github.com/xiaoqidun/pdfgo"
) )
@@ -30,8 +31,59 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict
if err != nil { if err != nil {
return err return err
} }
for _, annotation := range annotations { parents := make(map[pdfgo.Reference]int)
for i, annotation := range annotations {
if annotation.Reference != (pdfgo.Reference{}) && annotation.Subtype != "Popup" {
parents[annotation.Reference] = i
}
}
attached := make(map[int]bool)
for i, annotation := range annotations {
if annotation.Subtype != "Popup" {
continue
}
popup, err := p.reader.ReadPopupAnnotation(annotation.Dictionary)
if err != nil {
return err
}
if parent, ok := parents[popup.Parent]; ok {
dict := maps.Clone(annotations[parent].Dictionary)
if previous := dict["Popup"]; previous != nil {
if reference, ok := previous.(pdfgo.Reference); !ok || reference != annotation.Reference {
return fmt.Errorf("inconsistent PDF popup parent")
}
}
dict["Popup"] = annotation.Dictionary
annotations[parent].Dictionary = dict
attached[i] = true
} else {
annotations[i].Dictionary = popup.Dictionary
}
}
for i, annotation := range annotations {
if attached[i] {
continue
}
dict := annotation.Dictionary dict := annotation.Dictionary
popupObject := dict["Popup"]
if annotation.Subtype == "Popup" {
popupObject = dict
}
if popupObject != nil {
popup, err := p.reader.ReadPopupAnnotation(popupObject)
if err != nil {
return err
}
if annotation.Subtype != "Popup" && popup.Parent != (pdfgo.Reference{}) && popup.Parent != annotation.Reference {
return fmt.Errorf("inconsistent PDF popup parent")
}
if popup.Open {
if strict {
return &pdfgo.UnsupportedError{Feature: "initially open annotation popup"}
}
p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF popup open state retained as annotation parameter"})
}
}
if annotation.Subtype == "Sound" { if annotation.Subtype == "Sound" {
sound, err := p.reader.ReadSound(dict["Sound"]) sound, err := p.reader.ReadSound(dict["Sound"])
if err != nil { if err != nil {
+40
View File
@@ -36,6 +36,46 @@ const (
signASN1Sequence = 16 signASN1Sequence = 16
) )
// ParsedSignatureSeal 保存电子印章声明信息、图像和毫米尺寸,不代表验签或信任结果
type ParsedSignatureSeal struct {
Info SignatureSealInfo
Type string
Data []byte
Width float64
Height float64
}
// ParseSignatureSeal 解析独立SES印章供展示使用,不验证授权、有效期或制章者信任
// 入参: data DER编码的电子印章
// 返回: *ParsedSignatureSeal 印章信息及独立图像副本, error 解析错误
func ParseSignatureSeal(data []byte) (*ParsedSignatureSeal, error) {
var raw asn1.RawValue
rest, err := asn1.Unmarshal(data, &raw)
if err != nil || len(rest) != 0 || raw.Tag != asn1.TagSequence || raw.Class != asn1.ClassUniversal {
return nil, fmt.Errorf("invalid SES seal DER")
}
seal, err := parseSESSeal(raw)
if err != nil {
return nil, err
}
items, _ := asn1Children(raw.Bytes)
info, _ := asn1Children(items[0].Bytes)
picture, _ := asn1Children(info[3].Bytes)
width, err := asn1Integer(picture[2])
if err != nil || width <= 0 {
return nil, fmt.Errorf("invalid seal picture width")
}
height, err := asn1Integer(picture[3])
if err != nil || height <= 0 {
return nil, fmt.Errorf("invalid seal picture height")
}
format, image := probeSealMedia(seal.PicData)
if len(image) == 0 || format != seal.PicType {
return nil, fmt.Errorf("invalid seal picture")
}
return &ParsedSignatureSeal{Info: seal.Info, Type: format, Data: append([]byte(nil), image...), Width: float64(width), Height: float64(height)}, nil
}
// sesSignature SES签章值 // sesSignature SES签章值
type sesSignature struct { type sesSignature struct {
Timestamp []byte Timestamp []byte
+23
View File
@@ -21,6 +21,29 @@ import (
"strings" "strings"
) )
// SignaturePageStamps 生成所选页面相同毫米坐标的印章位置并检查页内范围
// 入参: pages 从1开始且不重复的页码, box 完整印章区域
// 返回: []SignatureStamp 可传入签署选项的位置, error 错误信息
func (r *Reader) SignaturePageStamps(pages []int, box Box) ([]SignatureStamp, error) {
if r == nil || len(pages) == 0 || !signatureWriteBoxValid(box) {
return nil, fmt.Errorf("invalid page stamp parameters")
}
doc, err := r.Doc()
if err != nil {
return nil, err
}
seen := make(map[int]bool)
stamps := make([]SignatureStamp, 0, len(pages))
for _, page := range pages {
if page < 1 || page > len(doc.Pages.Page) || seen[page] {
return nil, fmt.Errorf("invalid stamp page: %d", page)
}
seen[page] = true
stamps = append(stamps, SignatureStamp{PageRef: doc.Pages.Page[page-1].ID, Boundary: signatureWriteBox(box)})
}
return signatureWriteStamps(r, stamps, true)
}
// SignatureSeamStamps 生成页面右侧的等宽骑缝印章位置 // SignatureSeamStamps 生成页面右侧的等宽骑缝印章位置
// 页码从1开始,按传入顺序分割同一印章;box.X为右侧内缩,Y为纵坐标,W/H为完整印章尺寸 // 页码从1开始,按传入顺序分割同一印章;box.X为右侧内缩,Y为纵坐标,W/H为完整印章尺寸
// 入参: pages 至少两页且不重复, box 印章参数,单位毫米 // 入参: pages 至少两页且不重复, box 印章参数,单位毫米