""" 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 from pathlib import Path from src.models.field_issue import ( FieldIssue, CROSS_RECIPIENT_CONDITIONAL, UNSUPPORTED_OPERATOR, HIDE_ACTION, MULTI_PREDICATE, INVALID_PARENT_TAB, FIELD_TYPE_SKIPPED, PARTIAL_FIELD_TYPE, ) 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, issues: list, current_assignee: str = "", field_assignee: dict | None = None, ) -> 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. - Cross-recipient conditions not supported — DocuSign conditionals only work within a single recipient's tab set. - 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": msg = ( f"Field '{label}' has a HIDE condition which DocuSign does not support — " f"condition dropped. The field will always be visible." ) warnings.append(msg) issues.append(FieldIssue(HIDE_ACTION, label, msg).to_dict()) return tabs predicate = next((p for p in predicates if p.get("operator") == "EQUALS"), None) if not predicate: ops = [p.get("operator") for p in predicates] msg = ( f"Field '{label}' uses unsupported condition operator(s) {ops} — " f"only EQUALS is supported in DocuSign. Condition dropped; field will always be visible." ) warnings.append(msg) issues.append(FieldIssue(UNSUPPORTED_OPERATOR, label, msg).to_dict()) return tabs parent_field_name = predicate["fieldName"] # Cross-recipient check: DocuSign does not support conditionals across recipients if field_assignee is not None and current_assignee: parent_assignee = field_assignee.get(parent_field_name, "") if parent_assignee and parent_assignee != current_assignee: msg = ( f"Field '{label}' has a show/hide condition controlled by '{parent_field_name}', " f"which belongs to a different recipient ({parent_assignee} vs {current_assignee}). " f"DocuSign does not support cross-recipient conditional logic — condition dropped." ) warnings.append(msg) issues.append(FieldIssue(CROSS_RECIPIENT_CONDITIONAL, label, msg).to_dict()) return tabs if len(predicates) > 1: msg = ( f"Field '{label}' has {len(predicates)} conditions combined with " f"anyOrAll={ca.get('anyOrAll')} — only the first EQUALS predicate was mapped. " f"Remaining conditions were dropped." ) warnings.append(msg) issues.append(FieldIssue(MULTI_PREDICATE, label, msg).to_dict()) parent_value = predicate["value"] for tab_list in tabs.values(): for tab in tab_list: tab["conditionalParentLabel"] = parent_field_name tab["conditionalParentValue"] = parent_value return tabs # --------------------------------------------------------------------------- # Tab builder # --------------------------------------------------------------------------- def build_tabs_for_field(field: dict, warnings: list, issues: 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; a warning string and a structured FieldIssue are both appended so callers have both human-readable and machine-readable output. """ 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": return {"dateSignedTabs": _sized_tabs(locations, label)} elif content_type == "SIGNER_NAME": return {"fullNameTabs": _sized_tabs(locations, label)} elif content_type == "SIGNER_EMAIL": return {"emailAddressTabs": _sized_tabs(locations, label)} elif content_type in ("COMPANY", "SIGNER_COMPANY"): return {"companyTabs": _sized_tabs(locations, label)} elif content_type in ("TITLE", "SIGNER_TITLE"): return {"titleTabs": _sized_tabs(locations, label)} elif content_type == "DATA" and validation == "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": 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": 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": 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": msg = ( f"Field '{label}' is a FILE_CHOOSER — mapped to a signerAttachmentTabs tab. " f"DocuSign attachment tabs behave differently from Adobe file upload fields; manual review recommended." ) warnings.append(msg) issues.append(FieldIssue(PARTIAL_FIELD_TYPE, label, msg).to_dict()) tab = _make_base_tab(locations[0], label, {"optional": "true" if not field.get("required") else "false"}) return {"signerAttachmentTabs": [tab]} elif input_type == "INLINE_IMAGE": msg = f"Field '{label}' is an INLINE_IMAGE — skipped. There is no equivalent tab type in DocuSign." warnings.append(msg) issues.append(FieldIssue(FIELD_TYPE_SKIPPED, label, msg).to_dict()) return {} elif input_type == "STAMP": msg = ( f"Field '{label}' is a STAMP — mapped to stampTabs. " f"This requires the stamp feature to be enabled on your DocuSign account." ) warnings.append(msg) issues.append(FieldIssue(PARTIAL_FIELD_TYPE, label, msg).to_dict()) return {"stampTabs": [_make_base_tab(loc, label) for loc in locations]} elif input_type == "PARTICIPATION_STAMP": msg = f"Field '{label}' is a PARTICIPATION_STAMP — skipped. There is no equivalent tab type in DocuSign." warnings.append(msg) issues.append(FieldIssue(FIELD_TYPE_SKIPPED, label, msg).to_dict()) return {} else: msg = f"Field '{label}' has unknown type '{input_type}' (contentType='{content_type}') — skipped." warnings.append(msg) issues.append(FieldIssue(FIELD_TYPE_SKIPPED, label, msg).to_dict()) return {} def merge_tabs(acc: dict, new: dict) -> dict: for key, tabs in new.items(): acc.setdefault(key, []).extend(tabs) return acc # Tab types DocuSign forbids as conditional parents (auto-filled or action tabs) _INVALID_PARENT_TAB_TYPES = { "signHereTabs", "initialHereTabs", "dateSignedTabs", "fullNameTabs", "emailTabs", "titleTabs", "signerAttachmentTabs", } def _strip_invalid_conditionals(signers: list, warnings: list, issues: list) -> None: """ Remove conditionalParentLabel/Value from any tab whose parent label either doesn't exist in the template or points to a tab type DocuSign forbids as a parent (signature, initial, auto-filled). Mutates signers in place. """ for signer in signers: tabs = signer.get("tabs", {}) # Collect valid parent labels: only tab types allowed as parents valid_labels: set[str] = set() for tab_type, tab_list in tabs.items(): if tab_type in _INVALID_PARENT_TAB_TYPES: continue for tab in tab_list: lbl = tab.get("tabLabel") or tab.get("groupName") if lbl: valid_labels.add(lbl) # Strip references to invalid/missing parents for tab_list in tabs.values(): for tab in tab_list: parent = tab.get("conditionalParentLabel") if parent and parent not in valid_labels: field_name = tab.get("tabLabel") or tab.get("groupName") or "?" msg = ( f"Field '{field_name}' has a conditional that references parent " f"'{parent}', which either does not exist as a tab or is a " f"signature/auto-fill tab (forbidden as a DocuSign conditional parent). " f"Condition stripped — field will always be visible." ) warnings.append(msg) issues.append(FieldIssue(INVALID_PARENT_TAB, field_name, msg).to_dict()) tab.pop("conditionalParentLabel", None) tab.pop("conditionalParentValue", None) # --------------------------------------------------------------------------- # Main compose function # --------------------------------------------------------------------------- def compose_template(template_dir: str, output_path: str) -> tuple[dict, list[str], list[dict]]: """ 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, field_issues_list) field_issues_list contains structured FieldIssue dicts describing properties that were dropped or approximated during migration (see src/models/field_issue.py). """ template_dir = Path(template_dir) warnings: list[str] = [] issues: list[dict] = [] # 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": {}, }) # Build field→assignee lookup for cross-recipient conditional detection field_assignee: dict[str, str] = {} for f in fields: name = f.get("name", "") assignee = f.get("assignee") or f"recipient{max(f.get('signerIndex', 0), 0)}" if name: field_assignee[name] = assignee # 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, issues) tabs = _apply_conditional_to_tabs(tabs, field, warnings, issues, assignee, field_assignee) signers[idx]["tabs"] = merge_tabs(signers[idx]["tabs"], tabs) # Post-process: strip conditionalParentLabel references that point to # non-existent or invalid parents (signature/initial tabs can't be parents). _strip_invalid_conditionals(signers, warnings, issues) 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, issues # --------------------------------------------------------------------------- # 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, issues = 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}")