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:
parent
e655d8b4f5
commit
76568672d7
|
|
@ -1,18 +1,30 @@
|
||||||
# Field Mapping: Adobe Sign → DocuSign
|
# 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
|
## Field Type Mapping (inputType + contentType + validation → DocuSign tab)
|
||||||
| Adobe Sign Type | DocuSign Tab Type | Notes |
|
|
||||||
|---------------------|-------------------------|------------------------|
|
Adobe Sign requires `inputType`, `contentType`, and sometimes `validation` to determine the correct DocuSign tab.
|
||||||
| TEXT_FIELD | text | Valid for plain text |
|
Source: Adobe Sign UI "Change field type" dropdown (all 15 types) + API field data.
|
||||||
| SIGNATURE | signHere | |
|
|
||||||
| CHECKBOX | checkbox | |
|
| Adobe UI Label | inputType | contentType | validation | DocuSign Tab | Notes |
|
||||||
| DATE | dateSigned | May need transform |
|
|---------------------|-------------------|------------------|------------|----------------------|--------------------------------------------|
|
||||||
| RADIO | radio | Group mapping required |
|
| Signature | SIGNATURE | SIGNATURE | — | signHereTabs | |
|
||||||
| DROPDOWN | list | Data mapping |
|
| Initials | SIGNATURE | SIGNER_INITIALS | — | initialHereTabs | NOT a full signature |
|
||||||
| APPROVER | signer/approver role | DocuSign roles more flexible |
|
| 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
|
## Role/Recipient Mapping
|
||||||
| Adobe Field | DocuSign Field | Notes |
|
| 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 |
|
| recipientSetRole | role (signer, etc.) | Matching by role name |
|
||||||
| recipientSetMemberInfos.email | role.email | |
|
| 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 DocuSign’s `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)
|
## Workflow Feature Mapping (Rough)
|
||||||
- Sequential routing → Recipient order
|
- Sequential routing → Recipient order
|
||||||
- Parallel routing → Recipient routing order logic (sequential/parallel in DocuSign)
|
- Parallel routing → Recipient routing order logic (sequential/parallel in DocuSign)
|
||||||
- Conditional logic → Needs review, possible via DocuSign conditional tabs/logic
|
- 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
|
## To Do
|
||||||
- Add table for conditional logic/rule mapping
|
- Add table for conditional logic/rule mapping
|
||||||
- Add validation/transforms needed for field masks, validation, default values
|
- 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
|
- Collect pain points/edge cases for high-fidelity migration
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
Loading…
Reference in New Issue