feat: core migration — Adobe Sign to DocuSign field mapping and composition

compose_docusign_template.py — converts a downloaded template folder into a
DocuSign envelopeTemplate JSON ready for the Templates API. Key behaviours:

- Full field type mapping: TEXT_FIELD, SIGNATURE, CHECKBOX, RADIO, DROP_DOWN,
  BLOCK, FILE_CHOOSER (with warning), INLINE_IMAGE (skipped with warning)
- contentType dispatch: SIGNER_NAME → fullNameTabs, SIGNER_EMAIL →
  emailAddressTabs, SIGNATURE_DATE → dateSignedTabs, COMPANY/SIGNER_COMPANY →
  companyTabs, TITLE/SIGNER_TITLE → titleTabs, DATA+NUMBER → numberTabs,
  DATA+DATE → dateTabs, SIGNER_INITIALS → initialHereTabs
- Multi-location (cloned) fields: emits one tab per location with the same
  tabLabel so DocuSign tab merging replicates Adobe Sign's sync behaviour
- Width/height passed through from Adobe Sign locations; MIN_TEXT_WIDTH=120pt
  ensures text fields render as visible boxes rather than vertical lines
- Coordinate system: both platforms use top-left origin — no inversion needed

test_mapping.py — unit test harness validating tab grouping and field mapping.

field-mapping.md — full Adobe Sign → DocuSign tab type reference table with
edge cases, known gaps, and decision log.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Huliganga 2026-04-15 19:45:13 -04:00
parent e655d8b4f5
commit 76568672d7
3 changed files with 499 additions and 12 deletions

View File

@ -1,18 +1,30 @@
# Field Mapping: Adobe Sign → DocuSign
This doc will be used to track direct mappings and required transforms between Adobe Sign library document (template) properties and DocuSign template properties.
This doc tracks direct mappings and required transforms between Adobe Sign library document (template) properties and DocuSign template properties.
## Simple Field Type Mapping
| Adobe Sign Type | DocuSign Tab Type | Notes |
|---------------------|-------------------------|------------------------|
| TEXT_FIELD | text | Valid for plain text |
| SIGNATURE | signHere | |
| CHECKBOX | checkbox | |
| DATE | dateSigned | May need transform |
| RADIO | radio | Group mapping required |
| DROPDOWN | list | Data mapping |
| APPROVER | signer/approver role | DocuSign roles more flexible |
| ... | ... | ... |
## Field Type Mapping (inputType + contentType + validation → DocuSign tab)
Adobe Sign requires `inputType`, `contentType`, and sometimes `validation` to determine the correct DocuSign tab.
Source: Adobe Sign UI "Change field type" dropdown (all 15 types) + API field data.
| Adobe UI Label | inputType | contentType | validation | DocuSign Tab | Notes |
|---------------------|-------------------|------------------|------------|----------------------|--------------------------------------------|
| Signature | SIGNATURE | SIGNATURE | — | signHereTabs | |
| Initials | SIGNATURE | SIGNER_INITIALS | — | initialHereTabs | NOT a full signature |
| Recipient name | TEXT_FIELD | SIGNER_NAME | — | fullNameTabs | Auto-populated from signer profile |
| Recipient email | TEXT_FIELD | SIGNER_EMAIL | — | emailAddressTabs | Auto-populated from signer profile |
| Date of signing | TEXT_FIELD | SIGNATURE_DATE | — | dateSignedTabs | Auto-populated on signing |
| Text | TEXT_FIELD | DATA | STRING | textTabs | |
| Date | TEXT_FIELD | DATA | DATE | dateTabs | User-entered date (not auto-signed date) |
| Number | TEXT_FIELD | DATA | NUMBER | numberTabs | |
| Drop-down menu | DROP_DOWN | DATA | — | listTabs | Options from hiddenOptions array |
| Attachments | FILE_CHOOSER | DATA | — | signerAttachmentTabs | Manual review recommended |
| Participation stamp | PARTICIPATION_STAMP | — | — | (skipped) | No DocuSign equivalent |
| Image | INLINE_IMAGE | DATA | — | (skipped) | No DocuSign equivalent |
| Company | TEXT_FIELD | COMPANY or SIGNER_COMPANY | — | companyTabs | Auto-populated from signer profile. API returns `SIGNER_COMPANY` when set via UI. |
| Title | TEXT_FIELD | TITLE or SIGNER_TITLE | — | titleTabs | Auto-populated from signer profile. API returns `SIGNER_TITLE` when set via UI. |
| Stamp | STAMP | — | — | (skipped) | No DocuSign equivalent |
| Signature block | BLOCK | SIGNATURE_BLOCK | — | signHereTabs | Composite block — mapped to sign-here |
## Role/Recipient Mapping
| Adobe Field | DocuSign Field | Notes |
@ -20,12 +32,30 @@ This doc will be used to track direct mappings and required transforms between A
| recipientSetRole | role (signer, etc.) | Matching by role name |
| recipientSetMemberInfos.email | role.email | |
## Known Edge Cases & Decision Log
- [2026-04-14] DocuSign checkboxes must be uniquely tab-labeled and mapped to a recipient; Adobe Sign sometimes groups these differently.
- [2026-04-14] Date fields on Adobe may include validation Adobe-only, which needs stripping or custom mapping for DocuSigns `dateSigned`.
- [2026-04-14] Conditional logic for showing/hiding fields in Adobe is not always supported in DocuSign (needs review for each case).
- [2026-04-15] `numberTabs` API bug: DocuSign API accepts `numberTabs` in the template JSON, but the created template displays as a Text field with "Numbers" validation in the editor. Functionally equivalent at signing time; visual/semantic discrepancy only. No API workaround known.
- [2026-04-15] Multi-location fields: Adobe Sign fields can have multiple `locations` (cloned/synced instances). DocuSign equivalent is tab merging — multiple tabs with the same `tabLabel` sync their value. Our compose script now emits one tab per location for all data-entry types. See `PLATFORM-QUIRKS.md` for full details.
- [2026-04-15] Tab width required: DocuSign text-entry tabs render as a vertical line if `width` is omitted. Always pass `width` (and `height`) from the Adobe Sign location. Minimum 120pt enforced.
## Workflow Feature Mapping (Rough)
- Sequential routing → Recipient order
- Parallel routing → Recipient routing order logic (sequential/parallel in DocuSign)
- Conditional logic → Needs review, possible via DocuSign conditional tabs/logic
## Transform Formulas & Known Mapping Gaps
- **Coordinate translation:** If Adobe origin differs from DocuSign, map as:
`docusign_left = adobe_left // or apply offset, scale, etc.`
- **Radio group flattening:** Merge Adobe radios with `radioGroup` into DocuSign `radio` tab, setting all options explicitly.
- **Missing/ambiguous features:**
- DocuSign formulas (no mapping in Adobe Sign) — flag for manual rewrite
- Adobe advanced field validations (regex, custom scripts) — usually skipped or mapped to best-effort validation in DocuSign
## To Do
- Add table for conditional logic/rule mapping
- Add validation/transforms needed for field masks, validation, default values
- Document more edge cases as they are discovered in real samples
- Collect pain points/edge cases for high-fidelity migration

View File

@ -0,0 +1,353 @@
"""
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
Partial: FILE_CHOOSER signerAttachmentTabs (with warning)
Skipped: INLINE_IMAGE (no DocuSign equivalent warning logged)
"""
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]
# ---------------------------------------------------------------------------
# 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 in ("STAMP", "PARTICIPATION_STAMP"):
warnings.append(f"{input_type} '{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)
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}")

104
src/test_mapping.py Normal file
View File

@ -0,0 +1,104 @@
# Mapping Function Unit Test
"""
Unit test harness for mapping Adobe Sign form fields to DocuSign tab config.
Validates that compose_docusign_template.py produces correctly structured output:
- Tabs are grouped into typed arrays (textTabs, signHereTabs, etc.)
- required / locked are strings "true" / "false", not booleans
- listTabs have listItems: [{text, value}], not a string array
- radioGroupTabs have groupName + radios[]
- No top-level "status" field
- No email/name on signer role placeholders
"""
import json
import sys
from pathlib import Path
from pprint import pprint
# Support running from src/ or project root
BASE = Path(__file__).parent.parent
sys.path.insert(0, str(Path(__file__).parent))
from compose_docusign_template import compose_template
def test_onboarding_mapping():
output_path = BASE / "validation" / "compose-doc-template-complete.json"
compose_template(
fields_path=str(BASE / "sample-templates" / "onboarding-template-formfields.json"),
template_meta_path=str(BASE / "sample-templates" / "onboarding-template.json"),
pdf_b64_path=str(BASE / "sample-templates" / "onboarding-sample.pdf.b64"),
output_path=str(output_path),
)
template = json.loads(output_path.read_text())
# -- No top-level status field --
assert "status" not in template, "Template must not have a top-level 'status' field"
signers = template["recipients"]["signers"]
assert len(signers) == 2, f"Expected 2 signers, got {len(signers)}"
signer0_tabs = signers[0]["tabs"]
signer1_tabs = signers[1]["tabs"]
# -- No email/name on role placeholders --
for s in signers:
assert "email" not in s, f"Signer should not have 'email' in template role: {s['roleName']}"
assert "name" not in s, f"Signer should not have 'name' in template role: {s['roleName']}"
# -- Tab groups present and properly typed --
assert "textTabs" in signer0_tabs, "Signer 0 missing textTabs"
assert "dateSignedTabs" in signer0_tabs, "Signer 0 missing dateSignedTabs"
assert "listTabs" in signer0_tabs, "Signer 0 missing listTabs"
assert "checkboxTabs" in signer0_tabs, "Signer 0 missing checkboxTabs"
assert "radioGroupTabs" in signer0_tabs, "Signer 0 missing radioGroupTabs"
assert "signHereTabs" in signer0_tabs, "Signer 0 missing signHereTabs"
assert "textTabs" in signer1_tabs, "Signer 1 missing textTabs"
assert "signHereTabs" in signer1_tabs, "Signer 1 missing signHereTabs"
# -- required / locked are strings --
for tab in signer0_tabs.get("textTabs", []):
assert isinstance(tab.get("required"), str), f"required must be string, got {type(tab.get('required'))}"
for tab in signer0_tabs.get("listTabs", []):
assert isinstance(tab.get("required"), str), f"required must be string on listTab"
# -- listItems are objects --
list_tab = signer0_tabs["listTabs"][0]
assert "listItems" in list_tab, "listTab missing listItems"
assert isinstance(list_tab["listItems"][0], dict), "listItems entries must be {text, value} dicts"
assert "text" in list_tab["listItems"][0], "listItems entries must have 'text'"
assert "value" in list_tab["listItems"][0], "listItems entries must have 'value'"
# -- radioGroupTabs structure --
radio_tab = signer0_tabs["radioGroupTabs"][0]
assert "groupName" in radio_tab, "radioGroupTab missing groupName"
assert "radios" in radio_tab, "radioGroupTab missing radios"
assert len(radio_tab["radios"]) == 3, f"Expected 3 radios, got {len(radio_tab['radios'])}"
for r in radio_tab["radios"]:
assert "pageNumber" in r, "radio missing pageNumber"
assert "xPosition" in r, "radio missing xPosition"
assert "yPosition" in r, "radio missing yPosition"
assert "value" in r, "radio missing value"
# -- All tabs have required placement fields --
all_single_tabs = (
signer0_tabs.get("textTabs", [])
+ signer0_tabs.get("dateSignedTabs", [])
+ signer0_tabs.get("signHereTabs", [])
+ signer0_tabs.get("listTabs", [])
+ signer0_tabs.get("checkboxTabs", [])
+ signer1_tabs.get("textTabs", [])
+ signer1_tabs.get("signHereTabs", [])
)
for tab in all_single_tabs:
for field in ("documentId", "pageNumber", "xPosition", "yPosition"):
assert field in tab, f"Tab '{tab.get('tabLabel')}' missing '{field}'"
print("✅ All mapping assertions passed!")
print("\n--- Generated template (recipients section) ---")
pprint(template["recipients"])
if __name__ == "__main__":
test_onboarding_mapping()