adobe-to-docusign-migrator/src/compose_docusign_template.py

436 lines
17 KiB
Python

"""
compose_docusign_template.py
----------------------------
Converts a downloaded Adobe Sign template folder into a DocuSign
envelopeTemplate JSON that can be posted directly to the DocuSign
Templates API via:
node packages/esign-direct/build/cli.js templates create --file <output.json>
Key rules applied:
- Tabs grouped by type: textTabs, signHereTabs, dateSignedTabs,
listTabs, checkboxTabs, radioGroupTabs, signerAttachmentTabs
- required / locked must be strings "true" / "false", not booleans
- listTabs need listItems: [{text, value}] not a plain string array
- radioGroupTabs use groupName + radios[] with per-option page/x/y
- Coordinates: Adobe top is from TOP of page; DocuSign yPosition is
from BOTTOM. For US Letter (792pt): y = PAGE_HEIGHT - top - height
- Signers in templates are role-placeholders (no email/name)
- No top-level "status" field (belongs on envelope sends, not templates)
Field type coverage:
Mapped: TEXT_FIELD, SIGNATURE, CHECKBOX, DATE, DROP_DOWN, RADIO, BLOCK, STAMP
Partial: FILE_CHOOSER → signerAttachmentTabs (with warning)
STAMP → stampTabs (requires stamp feature enabled on DocuSign account — warning logged)
Skipped: INLINE_IMAGE, PARTICIPATION_STAMP (no DocuSign equivalent — warning logged)
Conditional logic:
Mapped: Single-predicate SHOW conditions (EQUALS operator) →
conditionalParentLabel + conditionalParentValue on the dependent tab.
For radio groups the parentLabel matches the radio group name.
Warnings: Multi-predicate ANY/ALL conditions (only first EQUALS predicate mapped),
HIDE action (not supported in DocuSign — condition skipped),
non-EQUALS operators (skipped).
"""
import base64
import json
import os
from pathlib import Path
DOCUMENT_ID = "1"
# ---------------------------------------------------------------------------
# Coordinate translation
# ---------------------------------------------------------------------------
# Minimum width for text-entry tabs so they render as a visible box rather
# than a vertical line. ~120pt ≈ 15 average characters at 8pt/char.
MIN_TEXT_WIDTH = 120
def loc_to_docusign(loc: dict) -> tuple[str, str, str, str, str]:
"""
Convert an Adobe Sign location dict to DocuSign (page, x, y, width, height) strings.
Both Adobe Sign and DocuSign measure from the top-left corner of the page,
with y increasing downward — no coordinate inversion needed.
Width is clamped to MIN_TEXT_WIDTH so text fields render as visible boxes.
"""
page = str(loc["pageNumber"])
x = str(int(loc["left"]))
y = str(int(loc["top"]))
width = str(max(int(loc.get("width", MIN_TEXT_WIDTH)), MIN_TEXT_WIDTH))
height = str(int(loc.get("height", 24)))
return page, x, y, width, height
# ---------------------------------------------------------------------------
# Recipients: derive from form field assignees
# ---------------------------------------------------------------------------
def derive_recipients(fields: list) -> list[dict]:
"""
Build an ordered list of recipient role placeholders from the assignee
values on each field (e.g. "recipient0", "recipient1").
Returns [{"assignee": "recipient0", "index": 0, "roleName": "Signer 1"}, ...]
"""
seen: dict[str, int] = {}
for f in fields:
assignee = f.get("assignee") or f"recipient{max(f.get('signerIndex', 0), 0)}"
if assignee not in seen:
try:
idx = int(assignee.replace("recipient", ""))
except ValueError:
idx = len(seen)
seen[assignee] = idx
recipients = sorted(
[{"assignee": k, "index": v, "roleName": f"Signer {v + 1}"} for k, v in seen.items()],
key=lambda r: r["index"],
)
return recipients if recipients else [{"assignee": "recipient0", "index": 0, "roleName": "Signer 1"}]
def assignee_to_index(assignee: str | None, recipients: list[dict]) -> int:
if not assignee:
return 0
for r in recipients:
if r["assignee"] == assignee:
return r["index"]
return 0
# ---------------------------------------------------------------------------
# Tab builder helpers
# ---------------------------------------------------------------------------
def _make_sized_tab(loc: dict, label: str, extra: dict | None = None) -> dict:
"""Build one sized DocuSign tab from a single Adobe Sign location."""
page, x, y, width, height = loc_to_docusign(loc)
tab = {
"tabLabel": label,
"documentId": DOCUMENT_ID,
"pageNumber": page,
"xPosition": x,
"yPosition": y,
"width": width,
"height": height,
}
if extra:
tab.update(extra)
return tab
def _make_base_tab(loc: dict, label: str, extra: dict | None = None) -> dict:
"""Build one unsized DocuSign tab (for signature/checkbox fields)."""
page, x, y, _w, _h = loc_to_docusign(loc)
tab = {
"tabLabel": label,
"documentId": DOCUMENT_ID,
"pageNumber": page,
"xPosition": x,
"yPosition": y,
}
if extra:
tab.update(extra)
return tab
def _sized_tabs(locations: list, label: str, extra: dict | None = None) -> list:
"""
Emit one sized tab per location with the same tabLabel.
Adobe Sign allows a single field to have multiple locations (cloned/linked
instances). DocuSign replicates this via tab merging: tabs that share a
tabLabel auto-sync their value at signing time. Applies to all data-entry
tab types: textTabs, numberTabs, dateTabs, dateSignedTabs, fullNameTabs,
emailAddressTabs, companyTabs, titleTabs, listTabs, checkboxTabs.
"""
return [_make_sized_tab(loc, label, extra) for loc in locations]
# ---------------------------------------------------------------------------
# Conditional logic
# ---------------------------------------------------------------------------
def _apply_conditional_to_tabs(tabs: dict, field: dict, warnings: list) -> dict:
"""
Apply DocuSign conditionalParentLabel / conditionalParentValue to tabs based
on an Adobe Sign conditionalAction.
Adobe Sign model:
conditionalAction.predicates[].{fieldName, operator, value}, action=SHOW|HIDE
DocuSign model (on the dependent tab):
conditionalParentLabel — tabLabel or radioGroup groupName of the trigger tab
conditionalParentValue — value the trigger must have to reveal this tab
Mapping limitations:
- Only SHOW action is supported. DocuSign has no native HIDE — condition skipped.
- Only EQUALS operator is supported. Others are skipped.
- Only one predicate is mapped. Multi-predicate ANY/ALL logic is not supported;
the first EQUALS predicate is used and a warning is logged.
"""
if not tabs:
return tabs
ca = field.get("conditionalAction", {})
predicates = ca.get("predicates", [])
if not predicates:
return tabs # No conditions — field is always visible
label = field.get("name", "unnamed")
action = ca.get("action", "SHOW")
if action != "SHOW":
warnings.append(
f"Conditional '{label}': action={action} is not supported in DocuSign "
f"(only SHOW is supported) — condition skipped"
)
return tabs
predicate = next((p for p in predicates if p.get("operator") == "EQUALS"), None)
if not predicate:
warnings.append(
f"Conditional '{label}': no EQUALS predicate found "
f"(operators: {[p.get('operator') for p in predicates]}) — condition skipped"
)
return tabs
if len(predicates) > 1:
warnings.append(
f"Conditional '{label}': {len(predicates)} predicates with "
f"anyOrAll={ca.get('anyOrAll')} — only first EQUALS predicate mapped, "
f"remaining conditions ignored"
)
parent_label = predicate["fieldName"]
parent_value = predicate["value"]
for tab_list in tabs.values():
for tab in tab_list:
tab["conditionalParentLabel"] = parent_label
tab["conditionalParentValue"] = parent_value
return tabs
# ---------------------------------------------------------------------------
# Tab builder
# ---------------------------------------------------------------------------
def build_tabs_for_field(field: dict, warnings: list) -> dict:
"""
Convert one Adobe Sign field into the correct DocuSign tabs structure.
Returns a dict of tab-group keys, e.g. {"textTabs": [...]}.
Unmappable fields are skipped and a warning is appended.
"""
input_type = field.get("inputType", "")
label = field.get("name", "unnamed")
locations = field.get("locations", [])
required_str = "true" if field.get("required", False) else "false"
locked_str = "true" if field.get("readOnly", False) else "false"
if not locations:
return {}
content_type = field.get("contentType", "")
validation = field.get("validation", "")
if input_type == "TEXT_FIELD":
if content_type == "SIGNATURE_DATE":
# Auto-populated with the signing date
return {"dateSignedTabs": _sized_tabs(locations, label)}
elif content_type == "SIGNER_NAME":
# Auto-populated with the signer's full name
return {"fullNameTabs": _sized_tabs(locations, label)}
elif content_type == "SIGNER_EMAIL":
# Auto-populated with the signer's email address
return {"emailAddressTabs": _sized_tabs(locations, label)}
elif content_type in ("COMPANY", "SIGNER_COMPANY"):
# Auto-populated with the signer's company
return {"companyTabs": _sized_tabs(locations, label)}
elif content_type in ("TITLE", "SIGNER_TITLE"):
# Auto-populated with the signer's title
return {"titleTabs": _sized_tabs(locations, label)}
elif content_type == "DATA" and validation == "DATE":
# User-entered date field (not auto-signed date)
return {"dateTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})}
elif content_type == "DATA" and validation == "NUMBER":
return {"numberTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})}
else:
return {"textTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})}
elif input_type == "SIGNATURE":
# Each signature/initials location is an independent signing action —
# emit one tab per location but do not size them (DocuSign controls size)
if content_type == "SIGNER_INITIALS":
return {"initialHereTabs": [_make_base_tab(loc, label) for loc in locations]}
else:
return {"signHereTabs": [_make_base_tab(loc, label) for loc in locations]}
elif input_type == "BLOCK" and content_type == "SIGNATURE_BLOCK":
# Composite signature block — map to signHere at block's location
return {"signHereTabs": [_make_base_tab(loc, label) for loc in locations]}
elif input_type == "DATE":
return {"dateSignedTabs": _sized_tabs(locations, label)}
elif input_type == "CHECKBOX":
return {"checkboxTabs": _sized_tabs(locations, label, {"required": required_str})}
elif input_type == "DROP_DOWN":
options = field.get("hiddenOptions") or field.get("visibleOptions") or []
list_items = [{"text": str(v), "value": str(v)} for v in options if v]
return {"listTabs": _sized_tabs(locations, label, {"required": required_str, "listItems": list_items})}
elif input_type == "RADIO":
# Each location is one radio button within the group — not tab merging
options = field.get("hiddenOptions") or []
radios = []
for i, loc in enumerate(locations):
pg, rx, ry, _rw, _rh = loc_to_docusign(loc)
value = options[i] if i < len(options) else str(i + 1)
radios.append({"pageNumber": pg, "xPosition": rx, "yPosition": ry, "value": value})
return {"radioGroupTabs": [{"groupName": label, "documentId": DOCUMENT_ID, "radios": radios}]}
elif input_type == "FILE_CHOOSER":
warnings.append(f"FILE_CHOOSER '{label}' → mapped to signerAttachmentTabs (manual review recommended)")
tab = _make_base_tab(locations[0], label, {"optional": "true" if not field.get("required") else "false"})
return {"signerAttachmentTabs": [tab]}
elif input_type == "INLINE_IMAGE":
warnings.append(f"INLINE_IMAGE '{label}' → skipped (no DocuSign equivalent)")
return {}
elif input_type == "STAMP":
# DocuSign stampTabs — signer uploads or selects a hanko/seal stamp image.
# Requires the stamp feature to be enabled on the DocuSign account.
warnings.append(f"STAMP '{label}' → stampTabs (verify stamp feature is enabled on your DocuSign account)")
return {"stampTabs": [_make_base_tab(loc, label) for loc in locations]}
elif input_type == "PARTICIPATION_STAMP":
warnings.append(f"PARTICIPATION_STAMP '{label}' → skipped (no DocuSign equivalent)")
return {}
else:
warnings.append(f"Unknown field type '{input_type}' (contentType='{content_type}') for field '{label}' → skipped")
return {}
def merge_tabs(acc: dict, new: dict) -> dict:
for key, tabs in new.items():
acc.setdefault(key, []).extend(tabs)
return acc
# ---------------------------------------------------------------------------
# Main compose function
# ---------------------------------------------------------------------------
def compose_template(template_dir: str, output_path: str) -> tuple[dict, list[str]]:
"""
Build a DocuSign template JSON from a downloaded Adobe Sign template folder.
Args:
template_dir: path to a downloads/<template-name>/ folder containing
metadata.json, form_fields.json, documents.json, and a PDF
output_path: where to write the resulting DocuSign template JSON
Returns:
(template_dict, warnings_list)
"""
template_dir = Path(template_dir)
warnings: list[str] = []
# Load source files
metadata = json.loads((template_dir / "metadata.json").read_text())
fields_data = json.loads((template_dir / "form_fields.json").read_text())
documents_data = json.loads((template_dir / "documents.json").read_text())
fields: list[dict] = fields_data.get("fields", [])
# Find the PDF file
pdf_files = [f for f in template_dir.iterdir() if f.is_file() and "json" not in f.name]
if not pdf_files:
raise FileNotFoundError(f"No PDF found in {template_dir}")
pdf_path = pdf_files[0]
pdf_b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
# Document name from documents.json
doc_info = documents_data.get("documents", [{}])[0]
doc_name = doc_info.get("name", pdf_path.name)
if not doc_name.lower().endswith(".pdf"):
doc_name = Path(doc_name).stem + ".pdf"
# Derive recipients from form field assignees
recipients = derive_recipients(fields)
# Build signers with empty tab groups
signers = []
for r in recipients:
signers.append({
"roleName": r["roleName"],
"recipientId": str(r["index"] + 1),
"routingOrder": str(r["index"] + 1),
"tabs": {},
})
# Assign tabs to the correct signer
for field in fields:
assignee = field.get("assignee") or f"recipient{max(field.get('signerIndex', 0), 0)}"
idx = assignee_to_index(assignee, recipients)
if idx >= len(signers):
idx = 0
tabs = build_tabs_for_field(field, warnings)
tabs = _apply_conditional_to_tabs(tabs, field, warnings)
signers[idx]["tabs"] = merge_tabs(signers[idx]["tabs"], tabs)
template = {
"name": metadata.get("name", template_dir.name),
"description": f"Migrated from Adobe Sign — original owner: {metadata.get('ownerEmail', '')}",
"documents": [
{
"documentBase64": pdf_b64,
"name": doc_name,
"fileExtension": "pdf",
"documentId": DOCUMENT_ID,
}
],
"recipients": {
"signers": signers,
},
}
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w") as f:
json.dump(template, f, indent=2)
return template, warnings
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import sys
downloads_dir = Path(__file__).parent.parent / "downloads"
if not downloads_dir.exists():
print("No downloads/ folder found. Run download_templates.py first.")
sys.exit(1)
for template_dir in sorted(downloads_dir.iterdir()):
if not template_dir.is_dir():
continue
output_path = Path(__file__).parent.parent / "migration-output" / template_dir.name / "docusign-template.json"
print(f"\n--- {template_dir.name} ---")
try:
_, warnings = compose_template(str(template_dir), str(output_path))
print(f" Written: {output_path}")
for w in warnings:
print(f" WARNING: {w}")
except Exception as e:
print(f" ERROR: {e}")