""" 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 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// 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}")