%% svg-animate — Animated SVG diagrams with TikZ
%% Copyright (C) 2026  Sébastien Gross
%%
%% This program is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% This program is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with this program.  If not, see <https://www.gnu.org/licenses/>.

\NeedsTeXFormat{LaTeX2e}
\def\svganimateversion{v1.0}
\def\svganimatedate{2026/03/16}
\ProvidesPackage{svg-animate}[\svganimatedate\space\svganimateversion\space Generate animated SVG diagrams with TikZ]

%% ── Engine detection ─────────────────────────────────────────────────────────
%% Must come before \RequirePackage{tikz}.
%% Only latex in DVI output mode needs the dvisvgm PGF and graphicx drivers.
%%
%% \pdfoutput is defined by pdfTeX (latex/pdflatex) and LuaTeX, with value:
%%   0 → DVI output (latex in DVI-compat mode) → needs dvisvgm
%%   1 → PDF output                            → default drivers are fine
%%
%% XeTeX does not define \pdfoutput at all, so the \ifdefined guard silently
%% skips the block for xelatex without any explicit \XeTeXversion check.
%%
%% \if@anim@svgmode is true only in DVI/SVG mode (latex → dvisvgm).
%% Use it to conditionalize content: \reveal is suppressed when false,
%% and \noanimate renders its argument only when false (static PDF).
\newif\if@anim@svgmode
%% Set by the /anim/noanimate key on \reveal to force rendering in PDF mode
%% even when \noanimate is present in the animate body.
\newif\if@anim@reveal@static
%% True while the animate environment's begin code is executing.
%% Used by \animstep to detect misuse outside animate.
\newif\if@anim@inside
%% True when the animation should loop (default).
%% Set to false via loop=false to produce a one-shot animation.
\newif\if@anim@loop \@anim@looptrue
%% True when the animate environment's static=true key was set.
%% When true, \reveal renders content at full opacity without keyframe animation.
\newif\if@anim@envstatic
%%
%% When running under latex in DVI mode (pdfoutput=0), configure three things:
%%
%%   1. \@anim@svgmodetrue
%%      Activates SVG-specific behaviour throughout the package: \reveal emits
%%      SMIL keyframe animations, \noanimate is suppressed, etc.
%%
%%   2. \def\pgfsysdriver{pgfsys-dvisvgm.def}
%%      Tells PGF/TikZ to use the dvisvgm backend instead of the default dvips
%%      backend.  This must be set BEFORE \RequirePackage{tikz} because PGF
%%      reads \pgfsysdriver at load time to select the system layer.  Without
%%      this, TikZ would emit PostScript specials (dvips) instead of SVG-aware
%%      specials, and dvisvgm would not produce a valid animated SVG.
%%
%%   3. \PassOptionsToPackage{dvisvgm}{graphicx}
%%      Ensures the graphicx package (loaded by \RequirePackage below) also
%%      uses the dvisvgm driver for \includegraphics.  Without this, graphicx
%%      would default to dvips and any raster image included in the document
%%      would not survive the DVI→SVG conversion.
%%
\ifdefined\pdfoutput
  \ifnum\pdfoutput=0
    \@anim@svgmodetrue
    \def\pgfsysdriver{pgfsys-dvisvgm.def}
    \PassOptionsToPackage{dvisvgm}{graphicx}
  \fi
\fi

\RequirePackage{tikz}
\RequirePackage{graphicx}
\usetikzlibrary{animations}

%% ── Animation ────────────────────────────────────────────────────────────────
%%
%% /anim/.cd keys:
%%
%%   duration=2            seconds per step (default)
%%   active opacity=1      opacity when the step is active
%%   inactive opacity=0    opacity when inactive (\reveal)
%%
\tikzset{
  /anim/.cd,
  duration/.initial          = 2,    %% seconds per step (default)
  active opacity/.initial    = 1,    %% opacity when the step is active
  inactive opacity/.initial  = 0,    %% opacity when inactive  (\reveal)
  blink on opacity/.initial  = 1,    %% opacity during blink "on"  half-periods
  blink off opacity/.initial = 0,    %% opacity during blink "off" half-periods
  noanimate/.code            = { \@anim@reveal@statictrue },
                               %% force this \reveal to render in PDF even when
                               %% \noanimate is present in the animate body
  loop/.is if                = @anim@loop,
  loop/.default              = true,
                               %% true (default): animation loops indefinitely
                               %% false: animation plays once and freezes on the last frame
  static/.code               = { \csname @anim@envstatic#1\endcsname },
  static/.default            = true,
                               %% true: all \reveal render at full opacity (no animation)
                               %% shorthand for the "show all steps simultaneously" idiom
}

%% ── Epsilon for instantaneous opacity snaps ──────────────────────────────────
%%
%% SVG SMIL interpolates linearly between consecutive keyframes.  To produce
%% an instantaneous jump (no cross-fade between steps), each transition is
%% represented as two keyframes separated by a very short interval:
%%
%%   (t - ε) s = "old_value"
%%   t s        = "new_value"
%%
%% Without this gap, setting two keyframes at exactly the same time leaves the
%% transition behaviour implementation-defined: some SVG engines treat it as
%% instantaneous, others as undefined.  The explicit ε makes the intent clear
%% and consistent across renderers.
%%
%% Why 0.001 s (1 ms)?
%%
%%   • Perceptually invisible: at 60 fps one frame lasts ≈ 16.7 ms, so 1 ms is
%%     well below the threshold of visibility even on high-refresh displays.
%%   • Numerically safe: even for long animations (e.g. total = 3600 s) the
%%     normalised keyTime  (3600 − 0.001) / 3600 ≈ 0.999 999 7  is distinct
%%     from 1.0 in double precision, so keyframes never collapse.
%%   • Practical lower bound: values much smaller than 1 ms (e.g. 1 μs) could
%%     be rounded away by some SVG renderers working in millisecond precision.
%%
%% Known limit: if a step duration is ≤ ε (e.g. duration=0.001) the epsilon
%% boundary equals or exceeds the step end, producing degenerate keyframes.
%% Such durations are not meaningful in practice.
%%
%% \anim@eps is a plain macro (expanded by \pgfmathsetmacro before evaluation).
%%
\def\anim@eps{0.001}

%% ════════════════════════════════════════════════════════════════════════════════
%% CATCODES — what they are, how they work, why they matter here
%% ════════════════════════════════════════════════════════════════════════════════
%%
%% ── What is a catcode? ────────────────────────────────────────────────────────
%%
%% TeX reads the source file one character at a time.  Before it can interpret
%% anything — before it knows whether '{' opens a group or '$' switches to maths
%% — it must assign a *syntactic role* to every character it reads.  That role
%% is the **category code** (catcode), an integer from 0 to 15.
%%
%% Each character has exactly one catcode at any given moment.  The full table:
%%
%%   0  escape        \          begins a control sequence name
%%   1  begin-group   {          opens a TeX group
%%   2  end-group     }          closes a TeX group
%%   3  math-shift    $          enters/exits math mode
%%   4  alignment     &          column separator in tabular/array/…
%%   5  end-of-line   ↵          treated as a space (or ignored, depending on state)
%%   6  parameter     #          introduces a macro argument: #1, #2, …
%%   7  superscript   ^          exponent in math mode
%%   8  subscript     _          subscript in math mode (also used by expl3)
%%   9  ignored       (none)     character is discarded, produces no token
%%  10  space         ⎵  \t     produces a space token (multiple → one)
%%  11  letter        a-z A-Z   part of a control-sequence name
%%  12  other         @ ! 1 ;…  produces a "character token", NOT part of a name
%%  13  active        ~          the character itself IS a macro (one-token command)
%%  14  comment       %          discards from here to end of line
%%  15  invalid       (rare)     triggers an error if encountered
%%
%% ── The critical rule: control-sequence names ────────────────────────────────
%%
%% When TeX reads '\', it scans subsequent characters to build a name.
%% It keeps reading as long as characters have catcode 11 (letter), and stops
%% at the first character with any other catcode.
%%
%%   Source         Catcodes of a,n,i,m,…   Result
%%   ────────────── ──────────────────────── ─────────────────────────────────────
%%   \animstep      all 11                  one token: control sequence \animstep
%%   \animstep{x}   all 11, then { = cc 1   \animstep + \bgroup + x + \egroup
%%   \@anim@foo     @ = cc 12               \@ + anim + @ + foo   (FOUR tokens!)
%%   \@anim@foo     @ = cc 11               one token: \@anim@foo ✓
%%
%% This is the entire reason \makeatletter exists: it changes '@' from catcode 12
%% to 11, allowing names like \if@anim@svgmode to be single tokens.
%% In a .sty file '@' is *always* catcode 11 (LaTeX sets it automatically before
%% loading any package), so \makeatletter is never needed in a .sty.
%%
%% ── Tokenisation is final ─────────────────────────────────────────────────────
%%
%% TeX converts the character stream into tokens exactly ONCE, when it reads each
%% line.  A token is a pair (character-code, catcode) and is immutable thereafter.
%% Changing a catcode later has no effect on tokens already read.
%%
%% The processing pipeline is:
%%
%%   Source file characters
%%         │
%%         ▼  catcodes applied HERE — one pass, irreversible
%%   Token stream  (each token carries its catcode permanently)
%%         │
%%         ▼
%%   Macro expansion  (tokens substituted according to \def rules)
%%         │
%%         ▼
%%   TeX execution   (grouping, typesetting, conditionals, …)
%%
%% Consequence: a \def whose body contains a space token (catcode 10) will
%% always carry that space token when the macro expands — even if the macro
%% is *called* inside an \ExplSyntaxOn block where spaces are catcode 9.
%% The body was tokenised at definition time (outside \ExplSyntaxOn), so the
%% space token is already baked in.  This is exactly the technique used by the
%% wrapper macros \@anim@get@active@opacity below.
%%
%% ── What \ExplSyntaxOn changes ────────────────────────────────────────────────
%%
%% expl3 (the modern LaTeX programming layer) needs its own naming conventions:
%% function names like \__anim_reveal_multistep:n embed '_' and ':' as
%% structural separators.  To make those characters legal inside names,
%% \ExplSyntaxOn reassigns four catcodes:
%%
%%   character   normal catcode   inside \ExplSyntaxOn   effect
%%   ─────────── ──────────────── ───────────────────── ──────────────────────────
%%   _           8  (subscript)   11 (letter)            part of a cs name
%%   :           12 (other)       11 (letter)            part of a cs name
%%   space       10 (space)        9 (ignored)           source whitespace ignored
%%   ~           13 (active)      10 (space)             explicit space substitute
%%
%% \ExplSyntaxOn does NOT touch '@', '#', '{', '}', or any other character.
%%
%% The space→9 and :→11 changes are the source of the three pitfalls documented
%% below.  Each pitfall arises because some LaTeX2e/pgf mechanism was designed
%% assuming the *normal* catcodes for those characters.
%%
%% ════════════════════════════════════════════════════════════════════════════════
%%
%% ── PITFALL A — pgfkeys paths with internal spaces inside \ExplSyntaxOn ───────
%%
%% \ExplSyntaxOn changes the catcode of the space character from 10 (space)
%% to 9 (ignored).  This is intentional: it lets expl3 code be written with
%% generous whitespace for readability, and that whitespace is silently dropped
%% during tokenisation rather than producing spurious space tokens.
%%
%% The consequence is that ANY space literal written in the SOURCE inside an
%% \ExplSyntaxOn block is discarded at tokenisation time — before any macro
%% expansion or execution takes place.
%%
%% pgfkeys stores and looks up keys by their *exact* path string, including any
%% spaces that are part of the key name.  The keys declared in this package are:
%%
%%   /anim/active opacity      ← space is part of the key name
%%   /anim/inactive opacity    ← idem
%%
%% Inside \ExplSyntaxOn, writing:
%%
%%   \pgfkeysvalueof{/anim/active opacity}
%%
%% causes the space between "active" and "opacity" to be catcode 9 at the
%% moment the source is tokenised.  TeX therefore never sees a space token
%% there; instead it reads the path string as "/anim/activeopacity" — a key
%% that has never been declared.
%%
%% The failure mode is NOT obvious:
%%   - No "undefined key" warning is emitted by pgfkeys in this context.
%%   - The undefined key returns an empty string.
%%   - That empty string is then passed to \pgfmathsetmacro, which tries to
%%     evaluate it as an arithmetic expression.
%%   - pgfmath fails internally on an empty expression, producing:
%%       ! Undefined control sequence: \pgfmath@dimen@
%%     which points into the depths of the pgfmath internals and gives no hint
%%     about the real cause (a missing space in a key path).
%%
%% Fix: define wrapper macros HERE, outside \ExplSyntaxOn, so the space token
%% in "/anim/active opacity" is tokenised at catcode 10 (normal) and baked
%% permanently into the macro body.  Calling the wrapper from inside
%% \ExplSyntaxOn is safe because TeX expands the macro body, not the source
%% text — the space token stored in the body retains its original catcode 10.
%%
\def\@anim@get@active@opacity{\pgfkeysvalueof{/anim/active opacity}}
\def\@anim@get@inactive@opacity{\pgfkeysvalueof{/anim/inactive opacity}}
\def\@anim@get@blink@on@opacity{\pgfkeysvalueof{/anim/blink on opacity}}
\def\@anim@get@blink@off@opacity{\pgfkeysvalueof{/anim/blink off opacity}}

%% ── PITFALL B — TikZ animation-spec parser requires ':' at catcode 12 ─────────
%%
%% \ExplSyntaxOn also changes the catcode of ':' from 12 (other) to 11 (letter).
%% This allows ':' to appear in expl3 function names as a separator between the
%% base name and the argument signature, e.g.  \__anim_reveal_multistep:n.
%%
%% TikZ's animation library (tikzlibraryanimations.code.tex) parses the
%% animate= key value using the following idiom to locate the ':' separator
%% between the target entity and the attribute:
%%
%%   \expandafter\pgfutil@in@\expandafter:\expandafter{\tikz@key}
%%
%% \pgfutil@in@ performs a token-level search: it looks for a token whose
%% *character code AND catcode* both match the searched-for token.  The ':'
%% hardcoded in the source above has catcode 12 (it was tokenised outside any
%% \ExplSyntaxOn block).  A ':' that was tokenised inside \ExplSyntaxOn carries
%% catcode 11 — a different token — and is NOT found by \pgfutil@in@.
%%
%% Consequence: if the macro body
%%
%%   \begin{scope}[animate={myself : opacity = {#1}}]
%%
%% is tokenised inside \ExplSyntaxOn, the ':' in "myself : opacity" becomes
%% catcode 11.  TikZ's parser then fails to find the entity:attribute boundary,
%% the spec is not recognised, and control falls through to an error path deep
%% inside pgfmath:
%%
%%   ! Undefined control sequence: \pgfmath@dimen@
%%
%% (same symptom as Pitfall A — completely unrelated-looking error).
%%
%% Fix: define this helper macro BEFORE \ExplSyntaxOn so that ':' in the macro
%% body is tokenised at catcode 12.  The function uses an expl3-style name
%% (__anim_emit_mf_scope:nn) which normally requires ':' to be catcode 11.
%% We resolve this tension with \csname...\endcsname: that construct assembles
%% a control-sequence name from arbitrary character tokens regardless of their
%% catcodes, so we can write the name without needing ':' or '_' to be letters.
%%
%% Why \long?
%%
%% Argument #2 is the raw TikZ content of a \reveal{...} block.  Users may
%% write blank lines inside a \reveal for readability:
%%
%%   \reveal{
%%
%%     \draw ...;
%%     \node ...;
%%   }
%%
%% A blank line produces a \par token in the token stream.  TeX normally
%% forbids \par inside macro arguments: if a macro is not declared \long,
%% scanning for its argument stops at the first \par and raises:
%%
%%   ! Paragraph ended before <macro> was complete.
%%
%% All callers up the chain (\reveal uses +m, expl3 functions are implicitly
%% long) already accept \par — this macro is the only non-expl3 link in the
%% chain, so \long must be declared here explicitly.
%%
%% The \long prefix is placed before the \expandafter chain.  TeX sets the
%% long-flag when it reads \long and the flag persists through the two
%% \expandafter expansions that resolve \csname...\endcsname, so the final
%% definition is effectively \long\protected\def <cs>#1#2{...}.
%%
\long\expandafter\protected\expandafter\def
  \csname __anim_emit_mf_scope:nn\endcsname#1#2{%
  \begin{scope}[animate={myself : opacity = {#1}}]%
    #2%
  \end{scope}%
}%

%% ── High-level animate environment ───────────────────────────────────────────
%%
%% ── The problem ──────────────────────────────────────────────────────────────
%%
%%   Each \reveal needs to know its own window of activity as absolute times:
%%
%%     step 1 active from 0 s to 1 s    (out of 3 s total)
%%     step 2 active from 1 s to 2 s
%%     step 3 active from 2 s to 3 s
%%
%%   But to compute those numbers we first need to know the total duration —
%%   and we are still in the middle of reading the body.  A naive single pass
%%   cannot work because we must know the total before we emit any keyframe.
%%
%% ── Step 1: collect the whole body without executing it (+b) ─────────────────
%%
%%   The +b argument spec in \NewDocumentEnvironment tells LaTeX to grab
%%   everything up to \end{animate} as a single token list (#2) and hand it
%%   to us verbatim, without executing a single command inside it.
%%   Given:
%%
%%     \begin{animate}[duration=1]
%%       \reveal{\node (vm1)...}
%%       \animstep
%%       \reveal{\node (vm2)...}
%%       \animstep[duration=3]
%%       \reveal{\node (vm3)...}
%%     \end{animate}
%%
%%   #2 is the raw token sequence:
%%     \reveal{\node (vm1)...} \animstep \reveal{\node (vm2)...}
%%     \animstep[duration=3] \reveal{\node (vm3)...}
%%
%% ── Step 2: split into per-step segments ─────────────────────────────────────
%%
%%   \seq_set_split:Nnn cuts #2 everywhere the token \animstep appears,
%%   producing a sequence of three token lists (still not executed):
%%
%%     seg 1:  \reveal{\node (vm1)...}
%%     seg 2:  \reveal{\node (vm2)...}
%%     seg 3:  [duration=3] \reveal{\node (vm3)...}   <- note the leading [opts]
%%
%%   The \animstep tokens themselves are consumed as delimiters and discarded.
%%
%% ── Step 3: peel off per-step [opts] ─────────────────────────────────────────
%%
%%   When the user writes \animstep[duration=3], the [duration=3] tokens end
%%   up at the front of the next segment (seg 3 above).  Before we can use
%%   the segment content as TikZ code we must extract those options first.
%%   \__anim_pop_opts:NN does this with a regex: if the segment starts with
%%   [...], it captures the content and strips it from the token list.
%%
%% ── Step 4 (pass 1): sum up the total duration ───────────────────────────────
%%
%%   We iterate over all segments without executing their TikZ content.
%%   For each segment we temporarily apply its options inside a TeX group
%%   (so they don't spill into the next step) and read /anim/duration.
%%   After the loop:  total = 1 + 1 + 3 = 5 s.
%%
%% ── Step 5 (pass 2): render each step with the correct timing ─────────────────
%%
%%   We iterate again, this time executing each segment's TikZ code.
%%   Before executing, we set three global FP variables:
%%     \g__anim_step_start_fp   e.g. 2.0
%%     \g__anim_step_end_fp     e.g. 5.0
%%     \g__anim_total_fp        5.0  (constant across all steps)
%%
%%   When \reveal{...} runs inside the segment, it reads those globals and
%%   passes them to \__anim_reveal_simple:n, which emits the SVG opacity keyframes.
%%   After execution, start advances to end, ready for the next step.
%%
%% ── User interface ────────────────────────────────────────────────────────────
%%
%%   \begin{animate}[options]
%%     \reveal[opts]{...}      active opacity when active, inactive opacity otherwise
%%     \animstep[opts]         step separator; opts apply to all elements of this step
%%     ...
%%   \end{animate}
%%
%%   Options cascade: animate-level → \animstep-level → per-element.
%%   Inner options override outer ones.
%%
%%   /anim/.cd keys:
%%     duration=2            seconds per step
%%     active opacity=1      opacity when active
%%     inactive opacity=0    opacity when inactive (0=hidden, >0=dimmed)
%%     blink on opacity=1    opacity during blink "on"  half-periods within active step
%%     blink off opacity=0   opacity during blink "off" half-periods within active step
%%                             (between steps the element uses inactive opacity as usual)
%%     static                render all \reveal at full opacity (no animation)
%%
%%   Note: 'duration' on \reveal is accepted but silently ignored.
%%
%%   Global defaults:
%%     \tikzset{/anim/duration=2}
%%     \tikzset{/anim/inactive opacity=0.3}
%%
%% ── PITFALL C — \ExplSyntaxOn does NOT change the catcode of '@' ──────────────
%%
%% \ExplSyntaxOn modifies exactly four catcodes: '_' (11), ':' (11),
%% space (9), '~' (10).  It deliberately leaves '@' unchanged.
%%
%% In a .sty file this is a non-issue: LaTeX sets '@' to catcode 11
%% (letter) before loading any package and restores it afterwards.
%% So '@' is always catcode 11 throughout a .sty file, even inside
%% \ExplSyntaxOn, with no \makeatletter needed.
%%
%% In a .tex document the situation is different: '@' is normally
%% catcode 12 (other) outside \makeatletter...\makeatother blocks.
%% If a user writes \ExplSyntaxOn directly in a .tex file without a
%% preceding \makeatletter, '@' stays catcode 12.  Then:
%%
%%   \if@anim@svgmode  →  \if  @  a  n  i  m  @  s  v  g  m  o  d  e
%%                         ^^^  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
%%                         \if primitive    individual "other" tokens
%%
%% TeX executes \if (primitive token comparison) instead of the boolean
%% flag set by \newif.  The two tokens compared are '@' (catcode 12) and
%% 'a' (catcode 11) — different, so \if is ALWAYS false.  SVG mode is
%% silently disabled with no error.
%%
%% This pitfall does not affect this package (we are in a .sty), but it
%% would affect any .tex file that mixes \ExplSyntaxOn with @-names.
%%
\ExplSyntaxOn

%% ── expl3 naming conventions ─────────────────────────────────────────────────
%%
%%   expl3 is the modern LaTeX programming layer.  Everything follows a strict
%%   naming scheme that encodes type, scope, and module in the name itself.
%%
%%   Data types (prefix before the first _):
%%     \fp_...   floating-point scalar (decimal arithmetic)
%%     \seq_...  ordered list of items
%%     \tl_...   token list (an arbitrary chunk of LaTeX code stored as a value)
%%
%%   Scope and visibility (prefix of the variable name after the type):
%%     \g__anim_...   global variable, private to this module (__ = private)
%%     \l__anim_...   local  variable, private to this module
%%     Global variables persist across TeX groups; local ones are restored.
%%
%%   Function signatures (suffix after the last :):
%%     :N    one unbraced token argument      e.g. \fp_new:N \myfp
%%     :Nn   one token + one braced argument  e.g. \tl_set:Nn \mytl {hello}
%%     :Ne   token + e-expanded braced arg    (content fully expanded before use)
%%     :NV   token + V-expanded arg           (variable replaced by its value)
%%     :NTF  token + true branch + false branch  (conditional)

%% Absolute timing of the step currently being rendered (seconds).
%% Set by animate before executing each step's content; read by \reveal.
\fp_new:N   \g__anim_step_start_fp   %% start time of the active step
\fp_new:N   \g__anim_step_end_fp     %% end   time of the active step
\fp_new:N   \g__anim_total_fp        %% total animation duration (all steps summed)
\fp_new:N   \g__anim_step_dur_fp     %% scratch: duration of the current step

%% List of per-step token lists produced by splitting the body at \animstep.
\seq_new:N  \l__anim_steps_seq
\seq_new:N  \l__anim_pop_seq         %% scratch: regex capture groups

%% Scratch token lists used inside the animate loops.
\tl_new:N   \l__anim_step_opts_tl   %% options extracted from \animstep[...]
\tl_new:N   \l__anim_seg_tl         %% one step's body (copy, modified in place)
\tl_new:N   \l__anim_dur_tl         %% string value of /anim/duration for FP arithmetic

%% True when the current animate body contains at least one \noanimate token.
%% Set by the animate environment before pass 2; checked by \reveal in PDF mode.
%% When true: \reveal is suppressed in PDF and \noanimate provides the static content.
%% When false: \reveal renders normally in PDF (all steps stacked, legacy behaviour).
\bool_new:N \g__anim_has_noanimate_bool

%% Per-step timing table: item N (1-based) = "start/end" decimal string.
%% Populated during pass 1; read by \__anim_reveal_multistep:n.
\seq_new:N \g__anim_step_times_seq
%% Running time cursor used during pass 1 to build \g__anim_step_times_seq.
\fp_new:N  \g__anim_cursor_fp

%% Blink period for the current \reveal call (0 = no blink).
%% Set by the /anim/blink key; reset to 0 at the top of each \reveal group.
\fp_new:N  \l__anim_blink_period_fp
\fp_new:N  \l__anim_blink_h_fp       %% scratch: half-period = blink_period / 2

%% step= spec for the current \reveal call (empty = use current step).
%% Set by the /anim/step key; cleared at the top of each \reveal group.
\tl_new:N  \l__anim_step_spec_tl
%% The /anim/step key must be declared here (inside \ExplSyntaxOn) so that
%% _ and : carry expl3 catcodes when the .code value is tokenised.
\tikzset{
  /anim/step/.code  = { \tl_set:Nn \l__anim_step_spec_tl { #1 } },
  /anim/blink/.code = { \fp_set:Nn \l__anim_blink_period_fp { #1 } },
}

%% Generate the nVNTF variant of \regex_extract_once to allow passing the
%% search string as a tl variable (V-expansion) rather than a literal brace group.
\cs_generate_variant:Nn \regex_extract_once:nnNTF { nVNTF }

%% \int_step_inline:nnnn loops {start}{step}{stop}{body}.
%% The 'eeen' variant e-expands start, step, stop so \seq_item:Nn results are
%% evaluated before the loop begins; the body is passed as-is.
\cs_generate_variant:Nn \int_step_inline:nnnn { eeen }

%% Scratch variables used by \__anim_reveal_multistep:n.
\seq_new:N  \l__anim_windows_seq     %% raw "start/end" windows, one per step
\seq_new:N  \l__anim_merged_seq      %% merged windows (adjacent steps collapsed)
\seq_new:N  \l__anim_step_items_seq %% items split from frame spec on ","
\seq_new:N  \l__anim_match_seq       %% regex capture groups
\tl_new:N   \l__anim_kf_tl          %% keyframe token list being built
\tl_new:N   \l__anim_ws_tl          %% scratch: window start time string
\tl_new:N   \l__anim_we_tl          %% scratch: window end time string
\tl_new:N   \l__anim_mstart_tl      %% scratch: current merged window start
\tl_new:N   \l__anim_mend_tl        %% scratch: current merged window end
\tl_new:N   \l__anim_next_tl        %% scratch: next window string
\bool_new:N \l__anim_merge_bool     %% scratch: true = active merged window exists

%% \animstep[options] — step delimiter; [options] apply to all elements of the next step.
%% When inside animate its body is collected verbatim (+b) so \animstep is consumed as
%% a split token by \seq_set_split and never actually executed — the error check below
%% can therefore only fire when \animstep is (incorrectly) used outside animate.
\NewDocumentCommand \animstep { } {
  \if@anim@inside \else
    \PackageError{svg-animate}
      {\string\animstep\space used outside animate environment}
      {%
        \string\animstep\space is a step separator and only makes sense%
        \MessageBreak inside \string\begin{animate}...\string\end{animate}.%
      }%
  \fi
}

%% \__anim_pop_opts:NN {#1} {#2}
%%   Extract a leading [opts] group from token list variable #2 into #1.
%%   If #2 starts with [...] (after trimming spaces), #1 receives the content
%%   between the brackets and the [...] is removed from #2.
%%   If there is no leading [...], #1 is cleared and #2 is left unchanged.
%%
%%   The regex  \A \[ ([^\]]*) \]  means:
%%     \A        start of string
%%     \[        literal [
%%     ([^\]]*)  capture group: any characters except ]
%%     \]        literal ]
\cs_new_protected:Npn \__anim_pop_opts:NN #1 #2 {
  \tl_trim_spaces:N #2                                           %% remove surrounding spaces
  \regex_extract_once:nVNTF { \A \[ ([^\]]*) \] } #2 \l__anim_pop_seq {
    %% Match found: capture group 2 holds the content between [ and ]
    \tl_set:Ne #1 { \seq_item:Nn \l__anim_pop_seq { 2 } }       %% #1 = captured opts
    \regex_replace_once:nnN { \A \[ [^\]]* \] } { } #2          %% strip [...] from #2
    \tl_trim_spaces:N #2                                         %% trim again after strip
  } {
    \tl_clear:N #1                                               %% no match: #1 = empty
  }
}

%% \__anim_reveal_multistep:n {content}
%%   Called by \reveal when the step= key is set.
%%   Reads \l__anim_step_spec_tl for the step specification (e.g. "2,5-8,10"),
%%   looks up per-step timings from \g__anim_step_times_seq, merges adjacent
%%   windows, then emits a single TikZ scope with multi-window SMIL keyframes.
%%
%%   Step timings are stored as "start/end" decimal strings (e.g. "1.5/2.0").
%%   Steps are 1-based.
%%
\cs_new_protected:Npn \__anim_reveal_multistep:n #1 {
  %% ── 1. Parse frame spec → raw windows sequence ───────────────────────────
  %% Split "2,5-8,10" on "," into items; expand each range into individual steps;
  %% look up each step's "start/end" timing from \g__anim_step_times_seq.
  \seq_clear:N \l__anim_windows_seq
  \seq_set_split:NnV \l__anim_step_items_seq { , } \l__anim_step_spec_tl
  \seq_map_inline:Nn \l__anim_step_items_seq {
    \regex_extract_once:nnNTF
        { \A \s* (\d+) \s* \- \s* (\d+) \s* \Z }
        { ##1 } \l__anim_match_seq
    {
      %% ── PITFALL D — '#' doubling in nested inline functions ──────────────────
      %%
      %% In a standard LaTeX macro definition, '#1' refers to the first argument.
      %% When one macro definition is *nested inside* another, every '#' must be
      %% doubled to reach the intended nesting level, because TeX halves the count
      %% of '#' characters each time it processes a definition body.
      %%
      %% The nesting here is three levels deep:
      %%
      %%   Level 0 — \cs_new_protected:Npn \__anim_reveal_multistep:n #1 { ... }
      %%             Parameter: #1 = TikZ content
      %%
      %%   Level 1 — \seq_map_inline:Nn \l__anim_step_items_seq { ... ##1 ... }
      %%             Inline function body; ##1 = current step spec item (e.g. "5-8").
      %%             TeX reduces '##' → '#' when scanning the level-0 body,
      %%             so '##1' in the source becomes '#1' at run time.
      %%
      %%   Level 2 — \int_step_inline:eeen { }{ }{ }{ ... ####1 ... }
      %%             Inline function nested inside the seq_map body.
      %%             We need the step counter to appear as '#1' inside this body
      %%             at run time.  Working backwards:
      %%               - level 1 reduces '##' → '#', so we need '##1' to survive
      %%                 level 1; that means we must write '##1' at level 1.
      %%               - But we are *writing inside* level 0, so level 0 will also
      %%                 halve our '#' count.  To have '##1' survive level 0 we
      %%                 must write '####1' in the source.
      %%             Reduction chain:  ####1  →(L0)→  ##1  →(L1)→  #1  ✓
      %%
      %% General rule:   N levels of inline nesting → 2^N '#' signs in source.
      %%
      %%   N=1: ##1   (one seq_map_inline or one int_step_inline, standalone)
      %%   N=2: ####1 (seq_map_inline → int_step_inline, as here)
      %%   N=3: ########1
      %%
      %% Using the wrong count silently produces the WRONG value with no error:
      %% '##1' at level 2 would evaluate to the seq_map's loop variable, i.e. the
      %% current step spec item ("5-8"), not the numeric step counter.
      %% \seq_item:Nn with a string index falls back to item 0 (empty), so every
      %% step in the range would get an empty timing string and produce garbage
      %% keyTimes in the SVG without any TeX diagnostic.
      %%
      %% \int_step_inline:nnnn {start}{step}{stop}{body} — four arguments.
      %% The 'eeen' variant e-expands arguments 1, 2, 3 before starting the loop,
      %% so \seq_item:Nn \l__anim_match_seq { 2 } is evaluated once to the integer
      %% string (e.g. "5") rather than being re-evaluated on every iteration.
      %% Argument 4 (the body) is not expanded — it contains '####1' which must
      %% remain as literal parameter tokens until the loop executes.
      %%
      %% Validate range bounds before looping.
      %% \l_tmpa_int = range start (A),  \l_tmpb_int = range end (B).
      %% All three error conditions are checked independently so the user
      %% sees every problem in a single compilation pass.
      \int_set:Nn \l_tmpa_int { \seq_item:Nn \l__anim_match_seq { 2 } }
      \int_set:Nn \l_tmpb_int { \seq_item:Nn \l__anim_match_seq { 3 } }
      \int_compare:nNnT { \l_tmpa_int } > { \l_tmpb_int } {
        \PackageError{svg-animate}
          {step=~range~(\int_use:N\l_tmpa_int-\int_use:N\l_tmpb_int)~is~invalid:~start~must~be~<=~end}
          {The~start~of~a~range~must~be~<=~its~end.}
      }
      \int_compare:nNnT { \l_tmpa_int } < { 1 } {
        \PackageError{svg-animate}
          {step=~range~start~(\int_use:N\l_tmpa_int)~is~out~of~range~(steps~start~at~1)}
          {The~step=~key~accepts~positive~integers~only.}
      }
      \int_compare:nNnT { \l_tmpb_int } > { \seq_count:N \g__anim_step_times_seq } {
        \PackageError{svg-animate}
          {step=~range~end~(\int_use:N\l_tmpb_int)~exceeds~the~number~of~steps~(\seq_count:N\g__anim_step_times_seq)}
          {This~animate~environment~has~\seq_count:N\g__anim_step_times_seq~
           step(s),~numbered~1~to~\seq_count:N\g__anim_step_times_seq.}
      }
      \int_step_inline:eeen
          { \seq_item:Nn \l__anim_match_seq { 2 } }
          { 1 }
          { \seq_item:Nn \l__anim_match_seq { 3 } }
          { \seq_put_right:Ne \l__anim_windows_seq
                { \seq_item:Nn \g__anim_step_times_seq { ####1 } } }
    } {
      %% Single step: validate then look up timing.
      %% \l_tmpa_int holds the step number for use in error messages.
      \tl_set:Ne \l__anim_dur_tl { \tl_trim_spaces:n { ##1 } }
      \int_set:Nn \l_tmpa_int { \l__anim_dur_tl }
      \int_compare:nNnT { \l_tmpa_int } < { 1 } {
        \PackageError{svg-animate}
          {step=~value~(\int_use:N\l_tmpa_int)~is~out~of~range~(steps~start~at~1)}
          {The~step=~key~accepts~positive~integers~only.}
      }
      \int_compare:nNnTF { \l_tmpa_int } > { \seq_count:N \g__anim_step_times_seq } {
        \PackageError{svg-animate}
          {step=~value~(\int_use:N\l_tmpa_int)~exceeds~the~number~of~steps~(\seq_count:N\g__anim_step_times_seq)}
          {This~animate~environment~has~\seq_count:N\g__anim_step_times_seq~
           step(s),~numbered~1~to~\seq_count:N\g__anim_step_times_seq.}
      } {
        \seq_put_right:Ne \l__anim_windows_seq
            { \seq_item:Nn \g__anim_step_times_seq { \l__anim_dur_tl } }
      }
    }
  }

  %% ── 2. Merge adjacent windows ─────────────────────────────────────────────
  %% Consecutive steps share a boundary (step N end == step N+1 start).
  %% Keeping them separate would produce conflicting keyframes at the junction,
  %% so we merge them into a single window: [step_A_start, step_B_end].
  \seq_clear:N \l__anim_merged_seq
  \bool_set_false:N \l__anim_merge_bool
  \seq_map_inline:Nn \l__anim_windows_seq {
    \regex_extract_once:nnNTF
        { \A ([0-9.]+) \/ ([0-9.]+) \Z }
        { ##1 } \l__anim_match_seq
    {
      \tl_set:Ne \l__anim_ws_tl { \seq_item:Nn \l__anim_match_seq { 2 } }
      \tl_set:Ne \l__anim_we_tl { \seq_item:Nn \l__anim_match_seq { 3 } }
      \bool_if:NTF \l__anim_merge_bool {
        \tl_if_eq:NNTF \l__anim_mend_tl \l__anim_ws_tl {
          %% Adjacent: extend the current merged window
          \tl_set_eq:NN \l__anim_mend_tl \l__anim_we_tl
        } {
          %% Gap: flush current window and start a new one
          \seq_put_right:Ne \l__anim_merged_seq
              { \l__anim_mstart_tl / \l__anim_mend_tl }
          \tl_set_eq:NN \l__anim_mstart_tl \l__anim_ws_tl
          \tl_set_eq:NN \l__anim_mend_tl   \l__anim_we_tl
        }
      } {
        %% First window ever
        \bool_set_true:N \l__anim_merge_bool
        \tl_set_eq:NN \l__anim_mstart_tl \l__anim_ws_tl
        \tl_set_eq:NN \l__anim_mend_tl   \l__anim_we_tl
      }
    } { }
  }
  \bool_if:NT \l__anim_merge_bool {
    \seq_put_right:Ne \l__anim_merged_seq
        { \l__anim_mstart_tl / \l__anim_mend_tl }
  }

  %% ── 3. Build SMIL keyframe token list ────────────────────────────────────
  %% Opacity values and total duration (same pgfmathsetmacro idiom as \__anim_reveal_simple:n).
  \pgfmathsetmacro\animft@opA    { \@anim@get@active@opacity  }
  \pgfmathsetmacro\animft@opI    { \@anim@get@inactive@opacity }
  %% Pre-expand \fp_to_decimal:N into a plain decimal string before pgfmath sees it,
  %% because pgfmath cannot call expl3 functions that take arguments.
  \tl_set:Ne \l__anim_dur_tl { \fp_to_decimal:N \g__anim_total_fp }
  \pgfmathsetmacro\animft@totale { \l__anim_dur_tl - \anim@eps }
  \tl_clear:N \l__anim_kf_tl

  %% Initial keyframe at t=0: active if first window starts at 0, else inactive.
  \tl_set:Ne \l__anim_ws_tl {
    \seq_item:Nn \l__anim_merged_seq { 1 }
  }
  \regex_extract_once:nVNTF
      { \A ([0-9.]+) \/ }
      \l__anim_ws_tl \l__anim_match_seq
  {
    \tl_set:Ne \l__anim_ws_tl { \seq_item:Nn \l__anim_match_seq { 2 } }
    \fp_compare:nNnTF { \l__anim_ws_tl } = { 0 } {
      \tl_put_right:Ne \l__anim_kf_tl { 0s = "\animft@opA", }
    } {
      \pgfmathsetmacro\animft@wse { \l__anim_ws_tl - \anim@eps }
      \tl_put_right:Ne \l__anim_kf_tl {
        0s = "\animft@opI",
        \animft@wse s = "\animft@opI",
        \l__anim_ws_tl s = "\animft@opA",
      }
    }
  } { }

  %% Per-window end keyframes, plus inter-window transitions where needed.
  \int_step_inline:nn { \seq_count:N \l__anim_merged_seq } {
    \tl_set:Ne \l__anim_seg_tl { \seq_item:Nn \l__anim_merged_seq { ##1 } }
    \regex_extract_once:nVNTF
        { \A ([0-9.]+) \/ ([0-9.]+) \Z }
        \l__anim_seg_tl \l__anim_match_seq
    {
      \tl_set:Ne \l__anim_ws_tl { \seq_item:Nn \l__anim_match_seq { 2 } }
      \tl_set:Ne \l__anim_we_tl { \seq_item:Nn \l__anim_match_seq { 3 } }
      \pgfmathsetmacro\animft@wee { \l__anim_we_tl - \anim@eps }
      %% Snap off at window end
      \tl_put_right:Ne \l__anim_kf_tl {
        \animft@wee s = "\animft@opA",
        \l__anim_we_tl s = "\animft@opI",
      }
      %% Transition to next window if there is one
      \int_compare:nNnT { ##1 } < { \seq_count:N \l__anim_merged_seq } {
        \tl_set:Ne \l__anim_next_tl
            { \seq_item:Nn \l__anim_merged_seq { ##1 + 1 } }
        \regex_extract_once:nVNTF
            { \A ([0-9.]+) \/ }
            \l__anim_next_tl \l__anim_match_seq
        {
          \tl_set:Ne \l__anim_ws_tl { \seq_item:Nn \l__anim_match_seq { 2 } }
          \pgfmathsetmacro\animft@wse { \l__anim_ws_tl - \anim@eps }
          \tl_put_right:Ne \l__anim_kf_tl {
            \animft@wse s = "\animft@opI",
            \l__anim_ws_tl s = "\animft@opA",
          }
        } { }
      }
    } { }
  }
  %% Stay inactive until end; append repeats unless loop=false.
  \tl_put_right:Ne \l__anim_kf_tl { \animft@totale s = "\animft@opI" }
  \if@anim@loop \tl_put_right:Nn \l__anim_kf_tl { ,repeats, } \fi

  %% ── 4. Emit TikZ scope with assembled keyframes ───────────────────────────
  %% \__anim_emit_mf_scope:Vn expands \l__anim_kf_tl to its string value before
  %% passing it to the scope, ensuring TikZ receives literal keyframe tokens.
  \__anim_emit_mf_scope:Vn \l__anim_kf_tl { #1 }
}

%% :Vn variant: value-expands \l__anim_kf_tl before passing it as #1,
%% guaranteeing the keyframe token list is fully resolved when TikZ
%% processes the animate= key.
\cs_generate_variant:Nn \__anim_emit_mf_scope:nn { Vn }

%% \__anim_reveal_simple:n {content}
%%   Called by \reveal when neither blink= nor step= is set.
%%   Builds the keyframe token list
%%   entirely in expl3 (using \tl_set:Ne + \if@anim@loop outside the spec) before
%%   passing it to TikZ via \__anim_emit_mf_scope:Vn.
%%
%%   This avoids the "doesn't match its definition" error caused by putting
%%   \if@anim@loop or any not-fully-expanded macro inside the animate={} spec.
%%
\cs_new_protected:Npn \__anim_reveal_simple:n #1 {
  %% Opacity values (wrappers defined before \ExplSyntaxOn — PITFALL A)
  \pgfmathsetmacro\animft@opA { \@anim@get@active@opacity  }
  \pgfmathsetmacro\animft@opI { \@anim@get@inactive@opacity }
  %% Pre-expand FP globals to decimal strings before pgfmath sees them (PITFALL A)
  \tl_set:Ne \l__anim_dur_tl { \fp_to_decimal:N \g__anim_total_fp }
  \tl_set:Ne \l__anim_ws_tl  { \fp_to_decimal:N \g__anim_step_start_fp }
  \tl_set:Ne \l__anim_we_tl  { \fp_to_decimal:N \g__anim_step_end_fp   }
  %% Epsilon boundaries
  \pgfmathsetmacro\animft@starte { max(\l__anim_ws_tl - \anim@eps, 0) }
  \pgfmathsetmacro\animft@ende   { \l__anim_we_tl - \anim@eps }
  \pgfmathsetmacro\animft@totale { \l__anim_dur_tl - \anim@eps }
  %% Build keyframe token list — \tl_set:Ne fully expands macros, so the
  %% resulting tl contains only literal decimal strings and punctuation.
  %% TikZ receives a pre-resolved string with no conditionals.
  \tl_clear:N \l__anim_kf_tl
  \fp_compare:nNnTF { \g__anim_step_start_fp } = { 0 } {
    %% Step starts at t=0 — active immediately.
    \tl_set:Ne \l__anim_kf_tl {
      0s = "\animft@opA",
      \animft@ende s = "\animft@opA",
      \l__anim_we_tl s = "\animft@opI",
      \animft@totale s = "\animft@opI"
    }
  } {
    %% Step starts after t=0 — inactive first.
    \tl_set:Ne \l__anim_kf_tl {
      0s = "\animft@opI",
      \animft@starte s = "\animft@opI",
      \l__anim_ws_tl s = "\animft@opA",
      \animft@ende s = "\animft@opA",
      \l__anim_we_tl s = "\animft@opI",
      \animft@totale s = "\animft@opI"
    }
  }
  %% Append repeats modifier outside the spec — \if@anim@loop is evaluated here
  %% in expl3 code, not inside the TikZ animate= value (which would confuse
  %% TikZ's animation parser and trigger "doesn't match its definition").
  \if@anim@loop \tl_put_right:Nn \l__anim_kf_tl { ,repeats, } \fi
  \__anim_emit_mf_scope:Vn \l__anim_kf_tl { #1 }
}

%% \__anim_reveal_blink:n {content}
%%   Called by \reveal when blink= key is set (period > 0).
%%
%%   Opacity rules:
%%     - Frame inactive (before/after active step): inactive opacity
%%     - Frame active, blink "on"  half-periods: blink on  opacity
%%     - Frame active, blink "off" half-periods: blink off opacity
%%
%%   blink on/off opacity govern ONLY the intra-step oscillation.
%%   Between steps the element follows inactive opacity, exactly like
%%   a non-blink \reveal.  Use inactive opacity=0 on a blink element to
%%   hide it between steps; the static key on the animate environment
%%   overrides all per-element opacity settings.
%%
%%   Half-period h = blink_period / 2.
%%   Number of half-periods: n_h = floor((e - s) / h), minimum 1.
%%   For k = 0 .. n_h-1:
%%     hold blink on  opacity when k is even (first half = visible)
%%     hold blink off opacity when k is odd  (second half = hidden)
%%   Before/after the step: hold inactive opacity.
%%
%%   The epsilon trick applies at each half-period boundary just as in
%%   Epsilon trick: (t_next - ε) holds the current opacity, then t_next snaps.
%%
\cs_new_protected:Npn \__anim_reveal_blink:n #1 {
  %% Opacity values (wrapper macros defined before \ExplSyntaxOn — PITFALL A).
  %% opI    = inactive opacity: used OUTSIDE the active step (before/after).
  %% opBon  = blink on  opacity: used for even (on)  half-periods within the step.
  %% opBoff = blink off opacity: used for odd  (off) half-periods within the step.
  \pgfmathsetmacro\animft@opI    { \@anim@get@inactive@opacity  }
  \pgfmathsetmacro\animft@opBon  { \@anim@get@blink@on@opacity  }
  \pgfmathsetmacro\animft@opBoff { \@anim@get@blink@off@opacity }
  %% Total duration and epsilon-before-total
  \tl_set:Ne \l__anim_dur_tl { \fp_to_decimal:N \g__anim_total_fp }
  \pgfmathsetmacro\animft@totale { \l__anim_dur_tl - \anim@eps }
  %% Step bounds as decimal strings (for keyframe timestamps)
  \tl_set:Ne \l__anim_ws_tl { \fp_to_decimal:N \g__anim_step_start_fp }  %% s
  \tl_set:Ne \l__anim_we_tl { \fp_to_decimal:N \g__anim_step_end_fp   }  %% e
  %% Half-period h = period / 2
  \fp_set:Nn \l__anim_blink_h_fp { \l__anim_blink_period_fp / 2 }
  %% n_h = floor((e - s) / h): number of complete half-periods in the step.
  %% floor() = trunc() for positive values.  Minimum 1 so the loop runs at least once
  %% (handles the case where the step is shorter than one full period).
  \int_set:Nn \l_tmpa_int {
    \fp_to_int:n { floor(
      ( \g__anim_step_end_fp - \g__anim_step_start_fp ) / \l__anim_blink_h_fp
    ) }
  }
  \int_compare:nNnT { \l_tmpa_int } < { 1 } { \int_set:Nn \l_tmpa_int { 1 } }
  %% Build keyframe token list
  \tl_clear:N \l__anim_kf_tl
  %% Initial inactive phase: hold opI from 0 to (s - ε), if s > 0.
  \fp_compare:nNnT { \g__anim_step_start_fp } > { 0 } {
    \pgfmathsetmacro\animft@starte { \l__anim_ws_tl - \anim@eps }
    \tl_put_right:Ne \l__anim_kf_tl {
      0s = "\animft@opI",
      \animft@starte s = "\animft@opI",
    }
  }
  %% ── PITFALL D: one level of \int_step_inline nesting inside \cs_new_protected
  %% → ##1 in source becomes #1 in the stored definition = loop counter at runtime.
  %%
  %% Loop k = 0 .. n_h-1.  \int_decr:N brings \l_tmpa_int to n_h-1 as the stop value.
  \int_decr:N \l_tmpa_int
  \int_step_inline:eeen { 0 } { 1 } { \int_use:N \l_tmpa_int } {
    %% t_start = s + k*h      (decimal string via FP)
    \tl_set:Ne \l__anim_mstart_tl {
      \fp_to_decimal:n { \g__anim_step_start_fp + ##1 * \l__anim_blink_h_fp }
    }
    %% t_end = min(s + (k+1)*h, e)   — caps the last half-period at the step boundary
    \tl_set:Ne \l__anim_mend_tl {
      \fp_to_decimal:n {
        min( \g__anim_step_start_fp + ( ##1 + 1 ) * \l__anim_blink_h_fp ,
             \g__anim_step_end_fp )
      }
    }
    %% Epsilon before t_end
    \pgfmathsetmacro\animft@wee { \l__anim_mend_tl - \anim@eps }
    %% Opacity for this half-period: blink on if k even, blink off if k odd.
    %% \tl_set:Ne expands \animft@opBon/opBoff to the decimal string immediately.
    \int_if_odd:nTF { ##1 } {
      \tl_set:Ne \l__anim_next_tl { \animft@opBoff }
    } {
      \tl_set:Ne \l__anim_next_tl { \animft@opBon }
    }
    %% Emit hold keyframes: opacity fixed at op for the interval [t_start, t_end - ε].
    %% The snap at t_end is handled either by the next iteration's t_start keyframe
    %% or by the final "e s = opI" keyframe below.
    \tl_put_right:Ne \l__anim_kf_tl {
      \l__anim_mstart_tl s = "\l__anim_next_tl",
      \animft@wee s = "\l__anim_next_tl",
    }
  }
  %% Snap to inactive opacity at step end; hold until total; optionally repeat.
  \tl_put_right:Ne \l__anim_kf_tl {
    \l__anim_we_tl s = "\animft@opI",
    \animft@totale s = "\animft@opI"
  }
  \if@anim@loop \tl_put_right:Nn \l__anim_kf_tl { ,repeats, } \fi
  %% Emit scope (PITFALL B: ':' at catcode 12 ensured by pre-ExplSyntaxOn helper)
  \__anim_emit_mf_scope:Vn \l__anim_kf_tl { #1 }
}

%% \reveal[options]{content}
%%   Wraps content in an opacity animation.
%%
%%   The key invariant: \g__anim_has_noanimate_bool is ALWAYS false in SVG mode
%%   (the scan that sets it is skipped when \if@anim@svgmode is true).  Therefore
%%   \bool_if:NTF below always takes the animation path in SVG mode — no explicit
%%   SVG/PDF branch needed, and the original animation behaviour is preserved.
%%
%%   PDF mode, no \noanimate in body:  bool=false  →  animation emitted, but TikZ
%%     animation keys are silently ignored by the PDF driver, so #2 is rendered at
%%     full opacity (all steps stacked — legacy behaviour).
%%   PDF mode, \noanimate present:     bool=true   →  animation suppressed.
%%     Render #2 only if this element carries the /anim/noanimate key.
%%
%%   'duration' in [options] is accepted but silently ignored (per-element timing
%%   has no meaning; duration is a step-level concept).
\NewDocumentCommand \reveal { O{} +m } {
  \@anim@reveal@staticfalse                                         %% reset per-element flag
  \group_begin:                                                     %% isolate option scope
    \tl_clear:N \l__anim_step_spec_tl                             %% reset step= spec
    \fp_set:Nn \l__anim_blink_period_fp { 0 }                    %% reset blink period
    \tikzset{ /anim/.cd, #1 }                                      %% may set flags/step spec
    \bool_if:NTF \g__anim_has_noanimate_bool {
      %% \noanimate present in body (PDF only — bool is always false in SVG mode).
      %% Render #2 only if this element explicitly carries the noanimate key.
      \if@anim@reveal@static #2 \fi
    } {
      %% Normal path: SVG mode (always) or PDF without \noanimate.
      %% static mode: render content at full opacity without any keyframe animation.
      \if@anim@envstatic #2 \else
      \fp_compare:nNnTF { \l__anim_blink_period_fp } > { 0 } {
        %% blink= set: oscillate during the current step.
        %% Warn if step= was also given — the combination is undefined and blink= wins.
        \tl_if_empty:NF \l__anim_step_spec_tl {
          \PackageWarning{svg-animate}
            {blink= and step= are mutually exclusive;\MessageBreak
             blink= takes priority; step= is ignored}
        }
        \__anim_reveal_blink:n { #2 }
      } {
        \tl_if_empty:NTF \l__anim_step_spec_tl {
          %% No step= spec: single window from current step globals (existing behaviour).
          \__anim_reveal_simple:n { #2 }
        } {
          %% step= spec present: multi-window reveal.
          \__anim_reveal_multistep:n { #2 }
        }
      }
      \fi                                                            %% end \if@anim@envstatic
    }
  \group_end:
}

%% \noanimate{content}
%%   PDF mode: renders content as a static element (no animation wrapper).
%%   SVG mode: completely ignored.
%%   Must be used inside \begin{animate}...\end{animate}; using it outside
%%   is an error because its meaning (fallback for which animation?) would
%%   be ambiguous.
\NewDocumentCommand \noanimate { +m } {
  \if@anim@inside \else
    \PackageError{svg-animate}
      {\string\noanimate\space used outside animate environment}
      {%
        \string\noanimate\space provides a static PDF fallback for a specific%
        \MessageBreak animate environment. Use it inside%
        \MessageBreak \string\begin{animate}...\string\end{animate}.%
      }%
  \fi
  \if@anim@svgmode
  \else
    #1
  \fi
}

\NewDocumentEnvironment { animate } { O{} +b } {
  %% #1 = animate-level options (e.g. "duration=1, inactive opacity=0")
  %% #2 = full body collected verbatim by +b, not yet executed
  \@anim@insidetrue                              %% guard: detect \animstep outside animate
  \@anim@envstaticfalse                          %% reset for each new environment
  \tikzset{ /anim/.cd, #1 }                      %% apply animate-level options globally

  %% PDF mode: scan the body for \noanimate before executing anything.
  %% \tl_if_in:NnTF  var  {tokens}  {true}  {false}
  %%   Checks whether the token \noanimate appears anywhere in the body.
  %%   When found, \reveal will be suppressed so that only \noanimate is rendered.
  %%   When absent, \reveal renders normally (legacy: all steps stacked in PDF).
  %% The flag is reset first so multiple animate environments are independent.
  \bool_gset_false:N \g__anim_has_noanimate_bool
  \if@anim@svgmode \else
    \tl_set:Nn \l__anim_seg_tl { #2 }
    \tl_if_in:NnT \l__anim_seg_tl { \noanimate } {
      \bool_gset_true:N \g__anim_has_noanimate_bool
    }
  \fi

  %% \seq_set_split:Nnn  target-seq  {delimiter-token}  {token-list}
  %% Cuts #2 everywhere \animstep appears; stores the pieces in \l__anim_steps_seq.
  %% Example result for 3 steps separated by two \animstep tokens:
  %%   item 1:  \reveal{\node (vm1)...}
  %%   item 2:  \reveal{\node (vm2)...}
  %%   item 3:  [duration=3] \reveal{\node (vm3)...}
  %% None of the items has been executed yet — they are inert token lists.
  \seq_set_split:Nnn \l__anim_steps_seq { \animstep } { #2 }

  %%
  %% Pass 1: sum up all step durations to get the total — without executing
  %%         any TikZ content.
  %%
  \fp_gzero:N \g__anim_total_fp       %% total = 0.0
  \fp_gzero:N \g__anim_cursor_fp     %% time cursor for building step timing table
  \seq_gclear:N \g__anim_step_times_seq  %% reset per-step timing table
  %%
  %% \seq_map_inline:Nn  seq  { body using ##1 }
  %% Loops over every item in the sequence.  Inside the body, ##1 is the
  %% current item.  (Double # because we are already inside \NewDocumentEnvironment
  %% which uses single # for its own arguments.)
  \seq_map_inline:Nn \l__anim_steps_seq {
    %%
    %% \tl_set:Nn  var  {value}  — assign a token list variable.
    %% We copy ##1 into a named variable so \__anim_pop_opts:NN can modify it
    %% (seq items are read-only; a local copy is needed).
    \tl_set:Nn \l__anim_seg_tl { ##1 }
    %%
    %% Strip the leading [opts] (e.g. [duration=3]) from \l__anim_seg_tl.
    %% The extracted opts string goes into \l__anim_step_opts_tl.
    %% If there were no [opts], \l__anim_step_opts_tl is left empty.
    \__anim_pop_opts:NN \l__anim_step_opts_tl \l__anim_seg_tl
    %%
    %% \group_begin: / \group_end:  — standard TeX grouping ({ ... }).
    %% Any \tikzset inside is local and does not leak into the next step.
    \group_begin:
      %%
      %% \tl_if_empty:NF  var  {code}  — run {code} only if var is NOT empty (:NF = N, False).
      \tl_if_empty:NF \l__anim_step_opts_tl {
        %% \exp_args:Ne  cmd  {arg}  — fully expand {arg} before passing it to cmd.
        %% \tl_use:N \l__anim_step_opts_tl expands to the string content of the variable,
        %% e.g. "duration=3".  Without :Ne, \tikzset would receive the unexpanded macro
        %% name instead of the string it holds.
        \exp_args:Ne \tikzset { /anim/.cd, \tl_use:N \l__anim_step_opts_tl }
      }
      %%
      %% \tl_set:Ne  var  {expr}  — assign var to the fully-expanded value of {expr}.
      %% Reads the current /anim/duration key (possibly just overridden by step opts)
      %% and stores it as a plain string like "3" or "1".
      \tl_set:Ne \l__anim_dur_tl { \pgfkeysvalueof{/anim/duration} }
      %%
      %% \exp_args:NNV  cmd  arg1  var  — call cmd with arg1 unchanged and var
      %% replaced by its value (:V expansion).  Equivalent here to:
      %%   \fp_gadd:Nn \g__anim_total_fp {"3"}   (the string "3" parsed as a number)
      %% \fp_gadd:Nn  fp-var  {fp-expr}  — adds the expression to the fp variable.
      %%
      %% Store "start/end" for this step before advancing the cursor.
      %% Item index N (1-based) in \g__anim_step_times_seq holds the timing of step N.
      \exp_args:NNV \fp_gset:Nn \g__anim_step_dur_fp \l__anim_dur_tl
      \seq_gput_right:Ne \g__anim_step_times_seq {
        \fp_to_decimal:N \g__anim_cursor_fp /
        \fp_eval:n { \g__anim_cursor_fp + \g__anim_step_dur_fp }
      }
      \fp_gadd:Nn \g__anim_cursor_fp { \g__anim_step_dur_fp }  %% advance cursor
      \exp_args:NNV \fp_gadd:Nn \g__anim_total_fp \l__anim_dur_tl
    \group_end:    %% step opts are rolled back; total remains (it is global \g_...)
  }
  %% After the loop, \g__anim_total_fp holds the sum of all step durations, e.g. 5.0.

  %%
  %% Pass 2: iterate again, this time executing each step's TikZ content.
  %%         We maintain a timing cursor (start) that advances step by step.
  %%
  \fp_gzero:N \g__anim_step_start_fp   %% cursor starts at t = 0.0
  \seq_map_inline:Nn \l__anim_steps_seq {
    \tl_set:Nn \l__anim_seg_tl { ##1 }
    \__anim_pop_opts:NN \l__anim_step_opts_tl \l__anim_seg_tl
    \group_begin:
      \tl_if_empty:NF \l__anim_step_opts_tl {
        \exp_args:Ne \tikzset { /anim/.cd, \tl_use:N \l__anim_step_opts_tl }
      }
      \tl_set:Ne \l__anim_dur_tl { \pgfkeysvalueof{/anim/duration} }
      %%
      %% Store the step duration as an FP variable so we can use it in an FP expression.
      %% (:NNV passes the value of \l__anim_dur_tl, e.g. the string "3", to \fp_gset:Nn.)
      \exp_args:NNV \fp_gset:Nn \g__anim_step_dur_fp \l__anim_dur_tl
      %%
      %% \fp_gset:Nn  fp-var  {fp-expr}  — evaluate an FP expression and store the result.
      %% FP variables (prefixed \g__ or \l__) are referenced directly in expressions.
      %% e.g. if start=2.0 and dur=3.0, this sets end=5.0.
      \fp_gset:Nn \g__anim_step_end_fp
        { \g__anim_step_start_fp + \g__anim_step_dur_fp }
      %%
      %% \tl_use:N  var  — expand and execute the token list.
      %% This is where the TikZ code (\reveal{...} etc.) actually runs.
      %% At this point the three timing globals hold the correct values for this step,
      %% so every \reveal inside will emit keyframes for the right time window.
      \tl_use:N \l__anim_seg_tl
      %%
      %% \fp_gset_eq:NN  a  b  — set a = b  (both are FP variables).
      %% Advance the cursor: next step starts where this one ended.
      %% This is global, so it survives the upcoming \group_end:.
      \fp_gset_eq:NN \g__anim_step_start_fp \g__anim_step_end_fp
    \group_end:
  }
} { \@anim@insidefalse }

\ExplSyntaxOff
