diff --git a/wwwroot/cgi-bin/awstats.model.conf b/wwwroot/cgi-bin/awstats.model.conf
index 12f066af5..973be2d61 100644
--- a/wwwroot/cgi-bin/awstats.model.conf
+++ b/wwwroot/cgi-bin/awstats.model.conf
@@ -1438,9 +1438,26 @@ color_x="C1B2E2" # Background color for number of exit pages (Default = "C1B2
# charts to be generated. The only data sent to Google includes the statistic numbers,
# legend names and country names.
# Warning: This plugin is not compatible with option BuildReportFormat=xhtml.
+# DEPRECATED: The Google Image Charts API used here was shut down by Google in
+# March 2019 (and the Flash-based Google Visualization GeoMap even earlier), so the
+# charts now render as broken images. Use the "graphsvg" plugin below instead.
#
#LoadPlugin="graphgooglechartapi"
+# PLUGIN: GraphSVG
+# REQUIRED MODULES: None
+# PARAMETERS: None
+# DESCRIPTION: Replaces the standard charts with self-contained, inline SVG drawn
+# locally: column charts for the time graphs (monthly, days of month, days of week,
+# hours), pie charts for the top-N sections (OS, browsers, file types, etc.) and a
+# ranking bar chart for the countries section. Unlike graphgooglechartapi it needs
+# no Internet access, no JavaScript and no external service, so charts keep working
+# offline and inside static reports, no visitor data ever leaves the server, and the
+# output is compatible with both html and xhtml BuildReportFormat. Drop-in
+# replacement: just load this plugin instead of graphgooglechartapi.
+#
+#LoadPlugin="graphsvg"
+
# PLUGIN: GeoIPfree
# REQUIRED MODULES: Geo::IPfree version 0.2+ (from Graciliano M.P.)
# PARAMETERS: None
diff --git a/wwwroot/cgi-bin/plugins/graphsvg.pm b/wwwroot/cgi-bin/plugins/graphsvg.pm
new file mode 100644
index 000000000..46b217907
--- /dev/null
+++ b/wwwroot/cgi-bin/plugins/graphsvg.pm
@@ -0,0 +1,567 @@
+#!/usr/bin/perl
+#-----------------------------------------------------------------------------
+# GraphSVG AWStats plugin
+# Draws AWStats graphs as self-contained, inline SVG (no external service).
+#
+# This is a drop-in replacement for the "graphgooglechartapi" plugin, whose
+# backends were shut down by Google: the Google Image Charts API used for the
+# bar/column and pie charts was turned off in March 2019, and the Flash-based
+# Google Visualization GeoMap used for the country map even earlier. As a
+# result those graphs render as broken images on every modern report.
+#
+# GraphSVG implements the very same ShowGraph hook and graph types, but draws
+# everything locally as inline SVG. Benefits:
+# * No external request, no JavaScript, no CDN -> graphs keep working
+# forever, offline, and inside static (BuildStaticPages) reports.
+# * No visitor data is ever sent to a third party (privacy / GDPR friendly).
+# * Vector output: crisp at any zoom, with native
hover tooltips.
+# * Compatible with both html and xhtml BuildReportFormat.
+#
+# The Google "geomap" country map cannot be reproduced without shipping a full
+# world geometry; it is replaced here by a horizontal ranking bar chart of the
+# top countries, which conveys the same information with zero extra data.
+#-----------------------------------------------------------------------------
+# Perl Required Modules: None
+#-----------------------------------------------------------------------------
+#
+# Changelog
+#
+# 1.0 - Initial release. Bar/column, pie and ranking charts as inline SVG.
+#-----------------------------------------------------------------------------
+
+# <-----
+# ENTER HERE THE USE COMMAND FOR ALL REQUIRED PERL MODULES
+# ----->
+no strict "refs";
+
+
+#-----------------------------------------------------------------------------
+# PLUGIN VARIABLES
+#-----------------------------------------------------------------------------
+# <-----
+# ENTER HERE THE MINIMUM AWSTATS VERSION REQUIRED BY YOUR PLUGIN
+# AND THE NAME OF ALL FUNCTIONS THE PLUGIN MANAGE.
+my $PluginNeedAWStatsVersion = "7.0";
+my $PluginHooksFunctions = "Init ShowGraph";
+my $PluginName = "graphsvg";
+
+# Geometry (in px). Width may be overridden per call by AWStats.
+my $imagewidth = 640; # default chart width
+my $barplotheight = 175; # height of the bar/column plot area
+my $pieradius = 78; # radius of pie charts
+my $PI = 3.14159265358979;
+
+# Per-call state, filled in by ShowGraph_graphsvg().
+my $title;
+my $type;
+my $blocklabel;
+my $vallabel;
+my $valcolor;
+my $valmax;
+my $valtotal;
+my $valaverage;
+my $valdata;
+# ----->
+
+# <-----
+# IF YOUR PLUGIN NEED GLOBAL VARIABLES, THEY MUST BE DECLARED HERE.
+use vars qw/
+$DirClasses
+/;
+# ----->
+
+#-----------------------------------------------------------------------------
+# PLUGIN FUNCTION: Init_pluginname
+#-----------------------------------------------------------------------------
+sub Init_graphsvg {
+ my $InitParams = shift;
+ my $checkversion = &Check_Plugin_Version($PluginNeedAWStatsVersion);
+
+ # <-----
+ # ENTER HERE CODE TO DO INIT PLUGIN ACTIONS
+ $DirClasses = $InitParams;
+ # ----->
+
+ return ( $checkversion ? $checkversion : "$PluginHooksFunctions" );
+}
+
+#-------------------------------------------------------
+# PLUGIN FUNCTION: ShowGraph_pluginname
+# UNIQUE: YES (Only one plugin using this function can be loaded)
+# Prints the proper chart depending on the $type provided
+# Parameters: $title $type $imagewidth \@blocklabel \@vallabel \@valcolor
+# \@valmax \@valtotal \@valaverage \@valdata
+# Input: None
+# Output: Inline SVG
+# Return: 0 OK, 1 Error
+#-------------------------------------------------------
+sub ShowGraph_graphsvg() {
+ $title = shift;
+ $type = shift;
+ $imagewidth = shift;
+ $blocklabel = shift;
+ $vallabel = shift;
+ $valcolor = shift;
+ $valmax = shift;
+ $valtotal = shift;
+ $valaverage = shift;
+ $valdata = shift;
+
+ # AWStats passes the relevant Show*Stats flag string (e.g. "HB") as the
+ # width argument for the time graphs, exactly as the old Google plugin did.
+ # Treat anything non-numeric or < 1 as "use the default width".
+ if ( !defined $imagewidth || $imagewidth !~ /^\d+$/ || $imagewidth < 1 ) {
+ $imagewidth = 640;
+ }
+
+ if ( $type eq 'month'
+ || $type eq 'daysofmonth'
+ || $type eq 'daysofweek'
+ || $type eq 'hours' )
+ {
+ # Keep the historical narrower week graph look.
+ my $w =
+ ( $type eq 'daysofweek' )
+ ? int( $imagewidth * 0.75 )
+ : $imagewidth;
+ print Graph_Bar($w);
+ }
+ elsif ($type eq 'cluster'
+ || $type eq 'filetypes'
+ || $type eq 'httpstatus'
+ || $type eq 'browsers'
+ || $type eq 'downloads'
+ || $type eq 'pages'
+ || $type eq 'oss'
+ || $type eq 'hosts' )
+ {
+ print Graph_Pie();
+ }
+ elsif ( $type eq 'countries_map' ) {
+ print Graph_Rank();
+ }
+ else {
+ debug( "Unknown type parameter in ShowGraph_graphsvg function: $type",
+ 1 );
+ }
+
+ return 0;
+}
+
+#-------------------------------------------------------
+# Small helpers
+#-------------------------------------------------------
+
+# Escape a string for safe inclusion in SVG/XML text and attributes.
+sub _esc {
+ my $s = shift;
+ $s = '' unless defined $s;
+ $s =~ s/&/&/g;
+ $s =~ s/</g;
+ $s =~ s/>/>/g;
+ $s =~ s/"/"/g;
+ $s =~ s/'/'/g;
+ return $s;
+}
+
+# Normalise an AWStats colour (hex without '#') to a CSS colour.
+sub _col {
+ my ( $hex, $default ) = @_;
+ $default = '4477DD' unless defined $default;
+ $hex = '' unless defined $hex;
+ $hex =~ s/[^0-9A-Fa-f]//g;
+ $hex = $default unless ( length($hex) == 3 || length($hex) == 6 );
+ return "#$hex";
+}
+
+sub _is_num {
+ my $v = shift;
+ return ( defined $v && $v =~ /^-?\d+(?:\.\d+)?$/ ) ? 1 : 0;
+}
+
+# A numeric value usable in maths: AWStats may pass "?" for empty averages.
+sub _num {
+ my $v = shift;
+ return _is_num($v) ? $v + 0 : 0;
+}
+
+# Lighten ($f > 0) or darken ($f < 0) a hex colour. $f in [-1, 1].
+sub _shade {
+ my ( $hex, $f ) = @_;
+ $hex = '' unless defined $hex;
+ $hex =~ s/[^0-9A-Fa-f]//g;
+ if ( length($hex) == 3 ) { $hex =~ s/(.)/$1$1/g; }
+ return "#888888" unless length($hex) == 6;
+ my @c = (
+ hex( substr( $hex, 0, 2 ) ),
+ hex( substr( $hex, 2, 2 ) ),
+ hex( substr( $hex, 4, 2 ) )
+ );
+ foreach my $i ( 0 .. 2 ) {
+ if ( $f >= 0 ) { $c[$i] = int( $c[$i] + ( 255 - $c[$i] ) * $f ); }
+ else { $c[$i] = int( $c[$i] * ( 1 + $f ) ); }
+ $c[$i] = 0 if $c[$i] < 0;
+ $c[$i] = 255 if $c[$i] > 255;
+ }
+ return sprintf( "#%02X%02X%02X", @c );
+}
+
+# Decode an AWStats block label. They may be encoded as "Jan\n2026" (two text
+# rows) and may carry trailing markers: '!' = week-end, ':' = "current" (bold).
+# Returns ($line1, $line2, $isweekend, $iscurrent).
+sub _label {
+ my $raw = shift;
+ $raw = '' unless defined $raw;
+ my $weekend = ( $raw =~ /!/ ) ? 1 : 0;
+ my $current = ( $raw =~ /:/ ) ? 1 : 0;
+ $raw =~ s/[!:]//g;
+ my ( $l1, $l2 ) = split( /\n/, $raw, 2 );
+ $l1 = '' unless defined $l1;
+ $l2 = '' unless defined $l2;
+ return ( $l1, $l2, $weekend, $current );
+}
+
+# Format a series value for display. The last series of every AWStats time
+# graph is always bandwidth (bytes), so format it with Format_Bytes.
+sub _fmtval {
+ my ( $v, $isbytes ) = @_;
+ if ($isbytes) { return Format_Bytes($v); }
+ if ( $v == int($v) ) { return Format_Number( int($v) ); }
+ return sprintf( "%.1f", $v );
+}
+
+#-------------------------------------------------------
+# PLUGIN FUNCTION: Graph_Bar
+# Grouped column chart for the month / daysofmonth / daysofweek / hours graphs.
+# Each series is scaled to its own (possibly shared) max, exactly like the old
+# Google column chart, so series with very different units stay comparable.
+#-------------------------------------------------------
+sub Graph_Bar {
+ my $W = shift || 640;
+ my $nblocks = scalar @$blocklabel;
+ my $nseries = scalar @$valcolor;
+ return '' if ( $nblocks < 1 || $nseries < 1 );
+
+ # Per-series scale max: honour the value AWStats provides (it deliberately
+ # shares one max across related series), else derive it from the data.
+ my @smax;
+ foreach my $s ( 0 .. $nseries - 1 ) {
+ my $m =
+ ( $valmax && _num( $valmax->[$s] ) > 0 ) ? _num( $valmax->[$s] ) : 0;
+ if ( $m <= 0 ) {
+ foreach my $b ( 0 .. $nblocks - 1 ) {
+ my $v = _num( $valdata->[ $b * $nseries + $s ] );
+ $m = $v if $v > $m;
+ }
+ }
+ $smax[$s] = ( $m > 0 ) ? $m : 1;
+ }
+
+ # Geometry.
+ my $padL = 8;
+ my $padR = 8;
+ my $padT = 10;
+ my $plotH = $barplotheight;
+
+ # Do any labels need a second text row?
+ my $tworow = 0;
+ foreach my $b ( 0 .. $nblocks - 1 ) {
+ my ( undef, $l2 ) = _label( $blocklabel->[$b] );
+ $tworow = 1 if length($l2);
+ }
+ my $xlabelH = $tworow ? 25 : 14;
+
+ my $plotW = $W - $padL - $padR;
+ my $groupW = $plotW / $nblocks;
+ my $gGap = ( $groupW > 6 ) ? $groupW * 0.18 : 0;
+ my $barsW = $groupW - $gGap;
+ my $barW = $barsW / $nseries;
+ my $plotTop = $padT;
+ my $plotBot = $padT + $plotH;
+
+ # Legend layout (deterministic: fixed slot width, wrap into rows).
+ my $slotW = 150;
+ my $perRow = int( $W / $slotW );
+ $perRow = 1 if $perRow < 1;
+ $perRow = $nseries if $perRow > $nseries;
+ my $legRows = int( ( $nseries + $perRow - 1 ) / $perRow );
+ my $legendTop = $plotBot + $xlabelH + 6;
+ my $H = $legendTop + $legRows * 16 + 2;
+
+ my @s;
+ push @s,
+ sprintf(
+'';
+ return join( "\n", @s ) . "\n";
+}
+
+#-------------------------------------------------------
+# PLUGIN FUNCTION: Graph_Pie
+# Pie chart with a legend. Segments use shades of the section colour (the same
+# single colour the old Google plugin received), separated by thin white gaps.
+#-------------------------------------------------------
+sub Graph_Pie {
+ my $nseg = scalar @$blocklabel;
+ return '' if ( $nseg < 1 );
+
+ my @vals;
+ my $sum = 0;
+ foreach my $i ( 0 .. $nseg - 1 ) {
+ my $v = _num( $valdata->[$i] );
+ $v = 0 if $v < 0;
+ $vals[$i] = $v;
+ $sum += $v;
+ }
+ return '' if ( $sum <= 0 );
+
+ my $base = ( $valcolor && defined $valcolor->[0] ) ? $valcolor->[0] : '4477DD';
+
+ # Geometry: pie on the left, legend on the right.
+ my $r = $pieradius;
+ my $pad = 12;
+ my $cx = $pad + $r;
+ my $cy = $pad + $r;
+ my $legendX = $cx + $r + 22;
+ my $rowH = 16;
+ my $legendH = $nseg * $rowH;
+ my $H = ( $legendH + 2 * $pad > 2 * $r + 2 * $pad )
+ ? $legendH + 2 * $pad
+ : 2 * $r + 2 * $pad;
+ my $W = 640;
+
+ my @s;
+ push @s,
+ sprintf(
+'';
+ return join( "\n", @s ) . "\n";
+}
+
+#-------------------------------------------------------
+# PLUGIN FUNCTION: Graph_Rank
+# Horizontal ranking bar chart, used in place of the (dead) Google GeoMap for
+# the countries section. Shows the top entries by value.
+#-------------------------------------------------------
+sub Graph_Rank {
+ my $n = scalar @$blocklabel;
+ return '' if ( $n < 1 );
+
+ # Pair labels with values, sort by value desc, keep the top ones.
+ my @rows;
+ foreach my $i ( 0 .. $n - 1 ) {
+ push @rows,
+ [ defined $blocklabel->[$i] ? $blocklabel->[$i] : '',
+ _num( $valdata->[$i] ) ];
+ }
+ @rows = sort { $b->[1] <=> $a->[1] } @rows;
+ my $topn = 15;
+ @rows = @rows[ 0 .. $topn - 1 ] if scalar @rows > $topn;
+
+ my $max = 0;
+ foreach my $rrow (@rows) { $max = $rrow->[1] if $rrow->[1] > $max; }
+ $max = 1 if $max <= 0;
+
+ my $W = 640;
+ my $pad = 10;
+ my $labelW = 150; # left column for the country name
+ my $valW = 90; # right column for the value
+ my $rowH = 20;
+ my $barH = 12;
+ my $barX = $pad + $labelW;
+ my $barMax = $W - $barX - $valW - $pad;
+ my $H = $pad * 2 + scalar(@rows) * $rowH;
+ my $fill = _col( $color_h, '4477DD' );
+
+ my @s;
+ push @s,
+ sprintf(
+'';
+ return join( "\n", @s ) . "\n";
+}
+
+1; # Do not remove this line