mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-30 01:27:26 +08:00
1 parent
31e67b2721
commit
5842522edf
12 files changed
+465
-42
No files matched your search
@@ -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="signSeal">电子印章</label><input id="signSeal" type="file" accept=".esl,.ses,.der" aria-label="SES电子印章"></div>
|
||||
<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="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>
|
||||
@@ -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>
|
||||
</form>
|
||||
</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">
|
||||
<form id="verifyForm">
|
||||
<h2 id="verifyTitle" class="form-title">验证签名</h2>
|
||||
|
||||
@@ -1041,6 +1041,43 @@ button:not(:disabled):hover {
|
||||
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 {
|
||||
width: 360px;
|
||||
}
|
||||
|
||||
+143
-7
@@ -1129,8 +1129,15 @@ el.signCancel.addEventListener("click", () => state.signing ? cancelSigning() :
|
||||
el.signPanel.addEventListener("cancel", event => {
|
||||
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);
|
||||
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", () => {
|
||||
if (!state.doc || document.body.hasAttribute("aria-busy")) return;
|
||||
el.verifyForm.reset();
|
||||
@@ -1343,6 +1350,10 @@ updateSidebarState();
|
||||
boot();
|
||||
|
||||
function handleKeyDown(event) {
|
||||
if (sealPreview) {
|
||||
if (event.key === "Escape") { event.preventDefault(); finishSealPosition(); }
|
||||
return;
|
||||
}
|
||||
if (event.defaultPrevented || event.isComposing || event.altKey) {
|
||||
return;
|
||||
}
|
||||
@@ -1457,7 +1468,7 @@ function handleKeyDown(event) {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -3831,6 +3842,7 @@ function updateFontPermissionHint() {
|
||||
}
|
||||
|
||||
function clearSecurityForms() {
|
||||
if (sealPreview) finishSealPosition(false);
|
||||
finishCredentials(null);
|
||||
clearEncryptionForm();
|
||||
el.signKey.value = "";
|
||||
@@ -4248,11 +4260,134 @@ function cancelSigning() {
|
||||
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() {
|
||||
const stamp = Boolean(el.signSeal.files?.length);
|
||||
el.signPlacementFields.hidden = !stamp;
|
||||
el.signPages.required = stamp;
|
||||
el.signX.disabled = !stamp || el.signPlacement.value === "seam";
|
||||
el.signPlacement.disabled = !stamp;
|
||||
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) {
|
||||
@@ -5637,6 +5772,7 @@ async function pasteEditorContent(event) {
|
||||
}
|
||||
|
||||
function copySelection(event) {
|
||||
if (sealPreview) return;
|
||||
if (event.target.closest?.("input, textarea, [contenteditable]")) {
|
||||
return;
|
||||
}
|
||||
@@ -7580,7 +7716,7 @@ function updateEditorTools() {
|
||||
const moveDisabled = selectionDisabled || selectedPages.some(index => !pageCan("move", index));
|
||||
el.movePagePrevButton.disabled = moveDisabled || selectedPages[0] === 0;
|
||||
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);
|
||||
updateObjectControls(canvasEditor.selected);
|
||||
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);
|
||||
renderSecurity();
|
||||
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.insertForm.inert = busy;
|
||||
el.pageForm.inert = busy;
|
||||
@@ -7746,7 +7882,7 @@ function setBusy(busy, text = "", percent = 0, status = "") {
|
||||
el.outlineList.inert = busy;
|
||||
el.navigationTabs.inert = busy;
|
||||
el.pageList.inert = busy;
|
||||
el.editorTools.inert = busy;
|
||||
el.editorTools.inert = busy || Boolean(sealPreview);
|
||||
el.fontList.inert = busy;
|
||||
if (!busy) {
|
||||
el.progressPanel.hidden = true;
|
||||
|
||||
@@ -9,7 +9,7 @@ require (
|
||||
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c
|
||||
github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6
|
||||
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/image v0.46.0
|
||||
golang.org/x/text v0.42.0
|
||||
|
||||
@@ -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/xiaoqidun/jbig2 v1.1.1 h1:SjliS9ZO0e9HX5oYUfBuXrfFsXhWricrSz+XY4zORDI=
|
||||
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-20260927142759-12ed20917f60/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ=
|
||||
github.com/xiaoqidun/pdfgo v0.0.0-20260927150509-ae8390858589 h1:dHH5wnrIyOKRpx3Dw4IiMgMEPMrNFdatfkoMKpX3Khk=
|
||||
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/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
|
||||
|
||||
+102
-21
@@ -286,6 +286,8 @@ func RunWASM() {
|
||||
registerAsyncCallback("ofdgoSaveDocument", saveDocument)
|
||||
registerAsyncCallback("ofdgoSaveEncrypted", saveEncrypted)
|
||||
registerAsyncCallback("ofdgoSaveSigned", saveSigned)
|
||||
registerCallback("ofdgoPreviewSignatureSeal", previewSignatureSeal)
|
||||
registerCallback("ofdgoSignaturePlacement", signaturePlacement)
|
||||
select {}
|
||||
}
|
||||
|
||||
@@ -4148,6 +4150,105 @@ func saveEncrypted(args []js.Value) (any, error) {
|
||||
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 调用库层最终包签署并在回验成功后交付浏览器
|
||||
// 入参: args 本机证书私钥、信任根、印章位置、写出回调及进度回调
|
||||
// 返回: any 保存结果, error 错误信息
|
||||
@@ -4200,30 +4301,10 @@ func saveSigned(args []js.Value) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count, err := currentSession.Reader.PageCount()
|
||||
options.Stamps, err = signatureStampsFromJS(input)
|
||||
if err != nil {
|
||||
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])
|
||||
if err := progress("sign", 0, 1); err != nil {
|
||||
|
||||
+21
-9
@@ -39,18 +39,30 @@ type PageAnnot struct {
|
||||
|
||||
// Annotation 页面注释
|
||||
type Annotation struct {
|
||||
ID string `xml:"ID,attr"`
|
||||
Type string `xml:"Type,attr"`
|
||||
Subtype string `xml:"Subtype,attr"`
|
||||
Creator string `xml:"Creator,attr"`
|
||||
LastModDate string `xml:"LastModDate,attr"`
|
||||
Visible *bool `xml:"Visible,attr"`
|
||||
NoZoom bool `xml:"NoZoom,attr"`
|
||||
NoRotate bool `xml:"NoRotate,attr"`
|
||||
Remark string `xml:"Remark"`
|
||||
ID string `xml:"ID,attr"`
|
||||
Type string `xml:"Type,attr"`
|
||||
Subtype string `xml:"Subtype,attr"`
|
||||
Creator string `xml:"Creator,attr"`
|
||||
LastModDate string `xml:"LastModDate,attr"`
|
||||
Visible *bool `xml:"Visible,attr"`
|
||||
NoZoom bool `xml:"NoZoom,attr"`
|
||||
NoRotate bool `xml:"NoRotate,attr"`
|
||||
Remark string `xml:"Remark"`
|
||||
Parameters *AnnotationParameters `xml:"Parameters,omitempty"`
|
||||
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开始,位置和尺寸使用页面毫米坐标
|
||||
type AnnotationInfo struct {
|
||||
ID string `json:"id"`
|
||||
|
||||
@@ -96,6 +96,28 @@ func (e *Editor) AddAnnotation(index int, annotation Annotation) (string, error)
|
||||
if 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))...)
|
||||
data, err := editorXMLContainer("Annot", attrs, content)
|
||||
if err != nil {
|
||||
|
||||
+15
-1
@@ -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"})
|
||||
}
|
||||
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 {
|
||||
region, err := NewShape(ShapeRectangle, Box{W: box.W, H: box.H})
|
||||
if err != nil {
|
||||
@@ -220,7 +234,7 @@ func pdfAnnotationType(subtype pdfgo.Name) string {
|
||||
return "Watermark"
|
||||
case "Highlight", "Underline", "Squiggly", "StrikeOut":
|
||||
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 ""
|
||||
|
||||
+53
-1
@@ -18,6 +18,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
@@ -30,8 +31,59 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict
|
||||
if err != nil {
|
||||
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
|
||||
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" {
|
||||
sound, err := p.reader.ReadSound(dict["Sound"])
|
||||
if err != nil {
|
||||
|
||||
@@ -36,6 +36,46 @@ const (
|
||||
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签章值
|
||||
type sesSignature struct {
|
||||
Timestamp []byte
|
||||
|
||||
@@ -21,6 +21,29 @@ import (
|
||||
"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 生成页面右侧的等宽骑缝印章位置
|
||||
// 页码从1开始,按传入顺序分割同一印章;box.X为右侧内缩,Y为纵坐标,W/H为完整印章尺寸
|
||||
// 入参: pages 至少两页且不重复, box 印章参数,单位毫米
|
||||
|
||||
Reference in new issue
Block a user