'Default', 'viewer' => array( 'h' => 900, 'v' => 620 ), 'mask' => array( 'page' => 1, 'x' => 350.0, 'y' => 40.0, 'width' => 200.0, 'height' => 60.0 ), 'otp_channels' => array( 'email' ), 'access_verification' => 0, 'language' => '', 'page_mode' => 'last_only', 'invite_channel' => 'email', 'send_sealed_copy' => false, ); return (array) apply_filters( 'equasign_embedded_default_type', $default ); } /** * All configured types, keyed by slug: the embedded default deep-merged with * the stored option (stored values win), with any custom types appended. The * 'default' type is guaranteed present. * * @return array */ public static function all() { // SELECTABLE types. This build offers the single fixed 'default' type; the set of // selectable types is provided by an extension unit not present here. Stored task // type configs remain resolvable (non-destructive, A1bis). return array( self::DEFAULT_TYPE => self::stored_types()[ self::DEFAULT_TYPE ] ); } /** * RAW type resolution (ungated): every stored type + a guaranteed complete 'default'. * Used to resolve the type DATA of an already-created task, so a stored task type * config stays resolvable; behaviours resolve to the fixed defaults here. * * @return array */ private static function stored_types() { $stored = get_option( self::OPT, array() ); $stored = is_array( $stored ) ? $stored : array(); $types = $stored; $types[ self::DEFAULT_TYPE ] = self::merge_type( self::embedded_default(), isset( $stored[ self::DEFAULT_TYPE ] ) && is_array( $stored[ self::DEFAULT_TYPE ] ) ? $stored[ self::DEFAULT_TYPE ] : array() ); return $types; } /** * A single type by slug, always resolvable: an unknown slug falls back to the * embedded default; a partial stored type is completed from the embedded one. * Resolves against the RAW store (not the gated all()), so a stored task type config * stays resolvable; behaviours resolve to the fixed defaults here. * * @param string $slug Type slug. * @return array */ public static function get( $slug ) { $slug = self::sanitize_slug( $slug ); $types = self::stored_types(); if ( isset( $types[ $slug ] ) && is_array( $types[ $slug ] ) ) { return self::merge_type( self::embedded_default(), $types[ $slug ] ); } return $types[ self::DEFAULT_TYPE ]; } /** * Viewer dimensions (pixels) resolved by the cascade: * explicit placeholder attributes → the type's viewer config → the * embedded default dimensions (always guaranteed). * Each of h (width) and v (height) is resolved independently. * * @param string $slug Type slug. * @param array $attrs Optional placeholder overrides { h?:int, v?:int }. * @return array{h:int,v:int} */ public static function viewer_dims( $slug, array $attrs = array() ) { $embedded = self::embedded_default(); $type = self::get( $slug ); $h = self::pos_int( isset( $attrs['h'] ) ? $attrs['h'] : null ); $v = self::pos_int( isset( $attrs['v'] ) ? $attrs['v'] : null ); if ( null === $h ) { $h = self::pos_int( isset( $type['viewer']['h'] ) ? $type['viewer']['h'] : null ); } if ( null === $v ) { $v = self::pos_int( isset( $type['viewer']['v'] ) ? $type['viewer']['v'] : null ); } if ( null === $h ) { $h = (int) $embedded['viewer']['h']; } if ( null === $v ) { $v = (int) $embedded['viewer']['v']; } // Clamp to a sane maximum so an oversized attribute cannot break the layout. return array( 'h' => min( 5000, $h ), 'v' => min( 5000, $v ) ); } /** * The default signature mask (zone, PDF points) for a type, falling back to * the embedded default mask. * * @param string $slug Type slug. * @return array{page:int,x:float,y:float,width:float,height:float} */ public static function mask( $slug ) { $embedded = self::embedded_default(); $type = self::get( $slug ); $mask = isset( $type['mask'] ) && is_array( $type['mask'] ) ? $type['mask'] : array(); $base = $embedded['mask']; return array( 'page' => isset( $mask['page'] ) ? max( 1, (int) $mask['page'] ) : (int) $base['page'], 'x' => isset( $mask['x'] ) ? (float) $mask['x'] : (float) $base['x'], 'y' => isset( $mask['y'] ) ? (float) $mask['y'] : (float) $base['y'], 'width' => isset( $mask['width'] ) ? (float) $mask['width'] : (float) $base['width'], 'height' => isset( $mask['height'] ) ? (float) $mask['height'] : (float) $base['height'], ); } /** * The OTP channels applicable to a type (the email channel). Availability is * further constrained at runtime by the signer's contact + the Brevo account; * this is the type-level allow-list. Falls back to the email channel. * * @param string $slug Type slug. * @return string[] */ public static function otp_channels( $slug ) { $type = self::get( $slug ); $ch = isset( $type['otp_channels'] ) && is_array( $type['otp_channels'] ) ? $type['otp_channels'] : array(); $allowed = array( 'email' ); $out = array(); foreach ( $allowed as $c ) { if ( in_array( $c, $ch, true ) ) { $out[] = $c; } } if ( $out ) { return $out; } return array( 'email' ); } /** * The default access-verification flag (0 in Free) for tasks of a type, unless * the caller overrides it at creation. * * @param string $slug Type slug. * @return int 0|1 */ public static function access_verification( $slug ) { return 0; // Free: inert default (always 0). } /** * The front display language configured on a type ('' when it defers to the * shop default). Normalized to its base code. * * @param string $slug Type slug. * @return string */ public static function language( $slug ) { $type = self::get( $slug ); return isset( $type['language'] ) ? self::normalize_lang( $type['language'] ) : ''; } /** * The signing-placement mode of a type. In the Free build this is the fixed * last-page placement ('last_only'); the resolver clamps to it whenever the * feature field is off. * * @param string $slug Type slug. * @return string */ public static function page_mode( $slug ) { // Returns the fixed last-page placement. A filter handler (not registered here) // may widen it; with none registered, the neutral filter yields that fixed placement. // Free build: no page-mode handler is registered, so the neutral filter returns // the fixed last-page placement — the only placement the Free build ever affixes. return (string) apply_filters( 'equasign_page_mode', 'last_only', $slug ); } /** * The default invitation channel of a type: 'email' (default) or 'none'. * * @param string $slug Type slug. * @return string */ public static function invite_channel( $slug ) { $type = self::get( $slug ); $c = isset( $type['invite_channel'] ) ? (string) $type['invite_channel'] : 'email'; return in_array( $c, array( 'email', 'none' ), true ) ? $c : 'email'; } /** * Whether tasks of this type send the sealed dossier to the signer after signing. * * @param string $slug Type slug. * @return bool */ public static function send_sealed_copy( $slug ) { // Returns false here: automatic delivery of the sealed dossier is provided by an // extension unit not present in this build. return false; } /** * Resolves the FRONT language of a signing task, in order: * 1. the per-task override (if provided at creation); * 2. the document Type's configured language; * 3. the shop default language (guard-rail). * Always returns a non-empty language code. * * @param string $slug Type slug. * @param string $task_lang The task's stored language (may be empty). * @return string */ public static function resolve_language( $slug, $task_lang = '' ) { $task_lang = self::normalize_lang( $task_lang ); if ( '' !== $task_lang ) { return $task_lang; } $type_lang = self::language( $slug ); if ( '' !== $type_lang ) { return $type_lang; } return EquaSign_Models::default_lang(); } /** * Renders a presentation template: replaces every {equasign-viewer ...} * placeholder with the HTML produced by $viewer_factory( array $dims ), where * $dims is the resolved { h, v } for that placeholder (its own h-size/v-size * attributes take precedence over the type/default via the cascade). If the * template carries no placeholder, the viewer is appended at the type/default * size so the document is always shown. * * The template HTML itself is returned as-is (it is operator-authored, already * sanitized with wp_kses_post on save); only the placeholders are expanded. * * @param string $template Presentation template HTML. * @param string $slug Type slug (for the dimension cascade). * @param callable $viewer_factory fn(array $dims): string. * @return string */ public static function render_presentation( $template, $slug, callable $viewer_factory ) { $pattern = '/\{' . preg_quote( self::VIEWER_TAG, '/' ) . '(\s+[^}]*)?\}/'; $found = 0; $out = preg_replace_callback( $pattern, static function ( $m ) use ( $slug, $viewer_factory, &$found ) { $found++; if ( $found > 1 ) { return ''; // Only the first placeholder yields the viewer (no duplicate stacks). } $attrs = self::parse_attrs( isset( $m[1] ) ? $m[1] : '' ); $dims = self::viewer_dims( $slug, $attrs ); return (string) call_user_func( $viewer_factory, $dims ); }, (string) $template ); if ( 0 === $found ) { $out .= (string) call_user_func( $viewer_factory, self::viewer_dims( $slug ) ); } return $out; } /** * Parses placeholder attributes: h-size / v-size (positive integers, pixels). * Unknown attributes are ignored. * * @param string $raw The raw attribute string after the tag name. * @return array{h?:int,v?:int} */ public static function parse_attrs( $raw ) { $attrs = array(); // Anchored to a word start (not \b, which would also match e.g. data-h-size); // case-insensitive; clamped so an absurd value cannot break the layout. if ( preg_match( '/(?:^|\s)h-size\s*=\s*["\']?(\d+)/i', (string) $raw, $m ) ) { $attrs['h'] = min( 5000, (int) $m[1] ); } if ( preg_match( '/(?:^|\s)v-size\s*=\s*["\']?(\d+)/i', (string) $raw, $m ) ) { $attrs['v'] = min( 5000, (int) $m[1] ); } return $attrs; } /* --------------------------------------------------------------------- */ /* Helpers. */ /* --------------------------------------------------------------------- */ /** * Deep-merges a stored type over a base type (stored scalar values win; * the technical facets carried over). * * @param array $base Base (embedded). * @param array $over Overriding (stored). * @return array */ private static function merge_type( array $base, array $over ) { $out = $base; if ( isset( $over['label'] ) && '' !== trim( (string) $over['label'] ) ) { $out['label'] = (string) $over['label']; } foreach ( array( 'h', 'v' ) as $k ) { if ( isset( $over['viewer'][ $k ] ) && (int) $over['viewer'][ $k ] > 0 ) { $out['viewer'][ $k ] = (int) $over['viewer'][ $k ]; } } if ( isset( $over['mask'] ) && is_array( $over['mask'] ) ) { $out['mask'] = array_merge( $out['mask'], array_intersect_key( $over['mask'], $out['mask'] ) ); } if ( isset( $over['otp_channels'] ) && is_array( $over['otp_channels'] ) ) { $out['otp_channels'] = self::clean_channels( $over['otp_channels'] ); } if ( isset( $over['access_verification'] ) ) { $out['access_verification'] = ( 0 === (int) $over['access_verification'] ) ? 0 : 1; } if ( isset( $over['language'] ) ) { $out['language'] = (string) $over['language']; } if ( isset( $over['invite_channel'] ) && in_array( $over['invite_channel'], array( 'email', 'none' ), true ) ) { $out['invite_channel'] = (string) $over['invite_channel']; } if ( isset( $over['send_sealed_copy'] ) ) { $out['send_sealed_copy'] = ! empty( $over['send_sealed_copy'] ); } return $out; } /** * Normalizes an OTP-channel list to the allowed subset ({email}), * preserving order and de-duplicating. Empty input → the email channel. * * @param mixed $channels Raw list. * @return string[] */ public static function clean_channels( $channels ) { $channels = is_array( $channels ) ? $channels : array(); $allowed = array( 'email' ); $out = array(); foreach ( $allowed as $c ) { if ( in_array( $c, $channels, true ) ) { $out[] = $c; } } if ( $out ) { return $out; } return array( 'email' ); } /** * Slug sanitizer for a type key (lowercase, [a-z0-9_-]). * * @param string $slug Raw. * @return string */ public static function sanitize_slug( $slug ) { $slug = strtolower( (string) $slug ); $slug = preg_replace( '/[^a-z0-9_-]+/', '-', $slug ); return trim( (string) $slug, '-' ); } /** * Normalizes a language code to its 2-letter base ('fr_FR' → 'fr'). * * @param string $lang Raw. * @return string */ public static function normalize_lang( $lang ) { $lang = strtolower( (string) $lang ); $lang = preg_replace( '/[^a-z].*$/', '', $lang ); return $lang; } /** * Positive integer or null. * * @param mixed $v Value. * @return int|null */ private static function pos_int( $v ) { if ( null === $v || '' === $v ) { return null; } $n = (int) $v; return $n > 0 ? $n : null; } }