%% 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 .
\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 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 #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